From 8909ee86eaa3505991b9993d3f378ff55bafe476 Mon Sep 17 00:00:00 2001 From: os-musk Date: Wed, 2 Sep 2026 06:41:25 +0000 Subject: [PATCH] fix(formula): prescribe the bare call shape for a stdlib function written as a method `validateExpression` refused `record.name.upper()` correctly and then handed the author the generic dialect trailer ("`predicate`s are bare CEL"), advice that cannot succeed on a source that already IS bare CEL and parses fine. #13821's unknown-name arm stays silent here by design -- `upper` IS advertised, so calling it "not a callable name" would replace a useless sentence with a false one -- so the class had no prescription at all. The name is right; the call SHAPE is wrong. The `type` class now carries two disjoint arms. The new one fires when the name cel-js reports IS in the bare-callable catalog `CEL_STDLIB_FUNCTIONS` AND the message shows a receiver form AND the environment does not register the name as a receiver method; it prescribes the bare call assembled from the SOURCE (`upper(record.name)`), because cel-js's message names the receiver's TYPE (`dyn.upper()`) and never the author's expression. A receiver that is not a plain dotted chain gets the call shape instead of an invented spelling. Keyed on catalog membership plus the environment's own record of the receiver form, never on call shape alone: the 33 receiver-only names (`split`, `map`, `getFullYear`) stay valid as receiver calls, and the seven both-forms names (`contains`, `endsWith`, `matches`, `size`, `startsWith`, `string`, `trim`) keep today's trailer when a receiver call of them faults on arguments. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../formula-receiver-call-prescription.md | 55 +++++++ packages/formula/src/unknown-function.test.ts | 68 +++++++- packages/formula/src/unknown-function.ts | 73 ++++++++- packages/formula/src/validate.test.ts | 150 +++++++++++++++++- packages/formula/src/validate.ts | 105 +++++++++++- 5 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 .changeset/formula-receiver-call-prescription.md diff --git a/.changeset/formula-receiver-call-prescription.md b/.changeset/formula-receiver-call-prescription.md new file mode 100644 index 0000000000..df2a37b31c --- /dev/null +++ b/.changeset/formula-receiver-call-prescription.md @@ -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. diff --git a/packages/formula/src/unknown-function.test.ts b/packages/formula/src/unknown-function.test.ts index 3d7b547648..b50af75143 100644 --- a/packages/formula/src/unknown-function.test.ts +++ b/packages/formula/src/unknown-function.test.ts @@ -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'; /** @@ -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.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'); + }); +}); diff --git a/packages/formula/src/unknown-function.ts b/packages/formula/src/unknown-function.ts index 1da7806c2a..7a7b115fcb 100644 --- a/packages/formula/src/unknown-function.ts +++ b/packages/formula/src/unknown-function.ts @@ -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 * @@ -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`), 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. @@ -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 | undefined; +interface RegisteredNames { + /** Every registered name, whichever call form(s) it occupies. */ + all: ReadonlySet; + /** The subset registered as a receiver method, `x.fn()`. */ + receiver: ReadonlySet; +} + +let registeredNames: RegisteredNames | undefined; -function registeredFunctionNames(): ReadonlySet { +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. */ @@ -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() }; } diff --git a/packages/formula/src/validate.test.ts b/packages/formula/src/validate.test.ts index d1eb5eb225..c651659b57 100644 --- a/packages/formula/src/validate.test.ts +++ b/packages/formula/src/validate.test.ts @@ -7,7 +7,7 @@ import { nearestName, CEL_STDLIB_FUNCTIONS, } from './validate'; -import { firstUndeclaredReference } from './cel-engine'; +import { buildEnv, firstUndeclaredReference } from './cel-engine'; describe('validateExpression (ADR-0032)', () => { describe('predicates (CEL)', () => { @@ -322,6 +322,154 @@ describe('validateExpression (ADR-0032)', () => { }); }); + // #14203 — the third leg of #7073 / #13821. A bare-callable stdlib name + // written as a METHOD (`record.name.upper()`) is refused correctly and then + // handed the dialect trailer, which cannot succeed: the source already IS + // bare CEL and parses fine. #13821's arm stays silent here by design — the + // name is right, so calling `upper` "not a callable name" would replace a + // useless sentence with a false one — which left this class with no + // prescription at all until now. + // + // The fence is catalog membership PLUS the environment's own record of the + // receiver form, never the call shape alone. Each negative below is one class + // that keying on shape alone would have swallowed, and the `upper(record.name)` + // control makes the pair a pair: the prescription this arm hands out has to + // type-check, or the repair is another sentence that cannot succeed. + describe('receiver call vs dialect: a bare-callable name written as a method (#14203)', () => { + const dialectTrailer = (role: 'predicate' | 'value') => + ` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`; + + it.each([ + { name: "the card's own shape", source: 'record.name.upper()', fn: 'upper', spelling: 'upper(record.name)' }, + { name: 'a nested receiver chain', source: 'record.owner.name.lower()', fn: 'lower', spelling: 'lower(record.owner.name)' }, + { name: 'a call inside a conjunction', source: "record.a == 1 && record.name.upper() == 'X'", fn: 'upper', spelling: 'upper(record.name)' }, + { name: 'a non-string stdlib function', source: 'record.due.daysFromNow()', fn: 'daysFromNow', spelling: 'daysFromNow(record.due)' }, + ])('prescribes the bare call assembled from the SOURCE — $name', ({ source, fn, spelling }) => { + const r = validateExpression('predicate', source); + expect(r.ok).toBe(false); + expect(r.errors).toHaveLength(1); + const { message } = r.errors[0]; + // The front half — cel-js's own vocabulary, matching the runtime fault — is kept. + expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /); + expect(message).toContain(`\`${fn}\` is callable bare, not as a method`); + expect(message).toMatch(/CALL-SHAPE fault, not a dialect mistake/); + // The spelling comes from the SOURCE: cel-js's message names the receiver's + // TYPE (`dyn.upper()`), so `record.name` exists nowhere in the fault text. + expect(message).toContain(`Write \`${spelling}\` instead.`); + // ⛔ The defect itself: the dialect trailer must NOT reach this class… + expect(message).not.toContain(dialectTrailer('predicate')); + expect(message).not.toMatch(/bare CEL/); + // …and this is not #13821's class, whose sentence would be false here. + expect(message).not.toMatch(/NAME fault/); + }); + + it('applies to the `value` role too — one producer, all ~10 slots', () => { + const r = validateExpression('value', 'record.name.lower()'); + expect(r.ok).toBe(false); + expect(r.errors[0].message).toMatch(/^invalid CEL value:/); + expect(r.errors[0].message).toContain('Write `lower(record.name)` instead.'); + expect(r.errors[0].message).not.toContain(dialectTrailer('value')); + }); + + // A prescription that cannot be DERIVED must not be invented — inventing one + // would repeat, one level up, the defect this card is about. The receiver + // still gets NAMED (the function and its call shape), just not spelled. + it.each([ + { name: 'an indexed receiver', source: 'record.tags[0].upper()' }, + { name: 'a parenthesised expression', source: '(record.a + record.b).upper()' }, + { name: 'a string literal', source: "'literal'.upper()" }, + { name: 'a chain that starts with an index — the near-miss', source: 'record.x[0].name.upper()' }, + ])('falls back to the call SHAPE when the receiver is not a plain chain — $name', ({ source }) => { + const message = validateExpression('predicate', source).errors[0].message; + expect(message).toContain('`upper` is callable bare, not as a method'); + expect(message).toContain('Write `upper(…)` with the receiver as its first argument instead.'); + // ⛔ No fabricated spelling. `record.x[0].name.upper()` is the measured + // near-miss: `name` is a plain chain, and it is not the receiver. + expect(message).not.toMatch(/Write `[A-Za-z_$][\w$]*\([^…)]/); + expect(message).not.toContain(dialectTrailer('predicate')); + }); + + // The exclusion is MEASURED, not remembered. Catalog membership alone would + // answer `record.name.contains()` — an arity fault on a legitimate receiver + // call — with "write `contains(record.name)`", which faults just as hard. + it('pins the advertised names the environment ALSO registers as receiver methods', () => { + const definitions = ( + buildEnv(() => new Date(0)) as unknown as { + getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> }; + } + ).getDefinitions().functions; + const receiverRegistered = new Set( + definitions.filter((fn) => fn.receiverType != null).map((fn) => fn.name), + ); + expect( + CEL_STDLIB_FUNCTIONS.filter((fn) => receiverRegistered.has(fn)).sort(), + 'a name crossing this line moves it between the two classes below — ' + + 'the arm keys on it, so re-derive both before making this green', + ).toEqual(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim']); + // …and the arm's own class is real on the other side of the line. + expect(receiverRegistered.has('upper')).toBe(false); + }); + + // ⛔ Negative, class 1: what must stay VALID. `split`/`map`/`getFullYear` + // are correct ONLY after a dot, so they never reach this arm at all — and + // the last row proves the prescription itself type-checks. + it.each([ + ["record.name.split(',')", 'a receiver-ONLY name, correctly written as a method'], + ['record.dates.map(d, d)', 'a comprehension macro'], + ['record.created.getFullYear()', 'a receiver-only date accessor'], + ["record.name.contains('x')", 'a BOTH-forms name, correctly written as a method'], + ['record.name.trim()', 'the same, with no arguments'], + ['upper(record.name)', 'THE PRESCRIPTION THIS ARM HANDS OUT — it must type-check'], + ])('%s stays valid and never reaches this arm (%s)', (source) => { + expect(validateExpression('predicate', source).ok).toBe(true); + }); + + // ⛔ Negative, class 2: a `type` fault this arm may not speak for keeps the + // trailer it has today, byte for byte. + it.each([ + { name: 'a BOTH-forms name faulting on ARITY, not on shape', source: 'record.name.contains()' }, + { name: 'the same for `startsWith`', source: 'record.name.startsWith()' }, + { name: 'the same for `matches`', source: 'record.n.matches()' }, + { name: 'a bare-callable name called BARE with wrong arguments', source: 'upper(1, 2)' }, + { name: 'an operator type mismatch — no call in it at all', source: "1 + 'a'" }, + ])('keeps the dialect trailer — $name', ({ source }) => { + const r = validateExpression('predicate', source); + expect(r.ok).toBe(false); + expect(r.errors[0].message).toContain(dialectTrailer('predicate')); + expect(r.errors[0].message).not.toMatch(/CALL-SHAPE fault/); + }); + + // ⛔ Negative, class 3: #13821's arm answers exactly the sources it answered + // before, in both call forms. + it.each([ + { name: 'an unknown METHOD', source: 'record.name.nosuchmethod()', fn: 'nosuchmethod' }, + { name: 'a receiver-only name used BARE', source: "split(record.name, ',')", fn: 'split' }, + { name: 'an unknown bare name', source: 'nosuchfn(record.name)', fn: 'nosuchfn' }, + ])("keeps #13821's unknown-name arm — $name", ({ source, fn }) => { + const message = validateExpression('predicate', source).errors[0].message; + expect(message).toContain(`\`${fn}\` is not a callable name here`); + expect(message).toMatch(/NAME fault, not a dialect mistake/); + expect(message).not.toMatch(/CALL-SHAPE fault/); + }); + + it('leaves the `bounds` prescription untouched — a class was routed, not the shared tail replaced', () => { + const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && '); + const message = validateExpression('predicate', overBudget).errors[0].message; + expect(message).toMatch(/SIZE fault, not a dialect mistake/); + expect(message).not.toMatch(/CALL-SHAPE fault/); + }); + + // ⛔ Same discipline as #13821's arm: what the catalog CONTAINS is being + // adjudicated separately, so a sentence asserting a count would be falsified + // by that ruling without failing here. + it('points at the callable set without asserting its size', () => { + const message = validateExpression('predicate', 'record.name.upper()').errors[0].message; + expect(message).toContain('`introspectScope`'); + expect(message).toContain('`CEL_STDLIB_FUNCTIONS`'); + expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/); + }); + }); + describe('templates', () => { it('accepts a valid {{ path }} template', () => { const r = validateExpression('template', 'Hot lead: {{ record.full_name }}'); diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index f1df9d9db4..e2b884a843 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -32,7 +32,11 @@ import { templateEngine } from './template-engine'; // REGISTERS it, to give the `@objectstack/lint` gate an existence verdict) must // agree on WHICH token cel-js was talking about, so the extraction is shared and // the pattern has one home. -import { callNameFromNoOverload } from './unknown-function'; +import { + callNameFromNoOverload, + isReceiverRegistered, + receiverCallNameFromNoOverload, +} from './unknown-function'; export type FieldRole = 'predicate' | 'value' | 'template'; @@ -348,6 +352,94 @@ function unknownFunctionHint(celMessage: string): string | null { ); } +/** + * A plain dotted identifier chain written immediately left of `.name(` in + * `source`, or `undefined` when the receiver is anything else. + * + * cel-js's message names the receiver's TYPE (`dyn.upper()`), never the + * author's expression, so the only place the real receiver exists is the source + * — and only the plain-chain shape can be lifted out of it safely. Anything + * else (`record.tags[0].upper()`, `(a + b).upper()`, `'lit'.upper()`) returns + * `undefined` so the caller prints a shape instead of a spelling: a + * prescription that cannot be DERIVED must not be invented, or the repair + * repeats the defect it fixes one level up. + * + * `name` reaches here having matched `[A-Za-z_$][\w$]*` AND been found in + * {@link CEL_STDLIB_FUNCTIONS}, so it carries no regex metacharacter. + * + * Deliberately whitespace-intolerant around the dots: `record . name . upper()` + * is legal CEL that nobody writes, and admitting it costs a quantifier that + * backtracks (the ReDoS concern the role scanners below document). It falls + * through to the generic sentence, which is correct, just less specific. + */ +function receiverChainInSource(source: string, name: string): string | undefined { + const call = new RegExp(`[.]${name}\\s*\\(`).exec(source); + if (!call) return undefined; + const before = source.slice(0, call.index); + const chain = /[A-Za-z_$][\w$]*(?:[.][A-Za-z_$][\w$]*)*$/.exec(before); + if (!chain) return undefined; + // The chain must START the receiver. A character that continues an expression + // leftwards means the real receiver is larger than what matched — e.g. + // `record.tags[0].name.upper()`, where the chain is `name` and the receiver + // is not. + if (chain.index > 0 && /[\w$.)\]'"]/.test(before[chain.index - 1])) return undefined; + return chain[0]; +} + +/** + * The prescription for the **receiver-call** arm of a `type` refusal — a + * bare-callable stdlib function written as a method, `record.name.upper()` + * (#14203). + * + * The third leg of the repair {@link boundsHint} (#7073) and + * {@link unknownFunctionHint} (#13821) made for their own classes, and the one + * neither could cover: the name is right, so the unknown-name arm stays silent + * by design (calling `upper` "not a callable name" would be a fresh false + * statement), and the fault is not size, so the bounds arm never sees it. What + * was left was the dialect trailer — "`predicate`s are bare CEL" — handed to an + * author whose source already IS bare CEL and parses fine. Advice that cannot + * succeed, for the third time in the same shape. + * + * It is a high-frequency AI-author mistake rather than 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)`. And the correct spelling is derivable from the + * fault itself, which is what makes a prescription honest here. + * + * ### The two keys, and why one of them is not enough + * + * **Membership of {@link CEL_STDLIB_FUNCTIONS}**, never the call shape alone. + * 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 here at all, and telling anyone to write + * `split(record.name, ',')` would break working authoring. + * + * **Plus the environment's own record of the receiver form.** Seven advertised + * names hold definitions of BOTH kinds — `contains`, `endsWith`, `matches`, + * `size`, `startsWith`, `string`, `trim` — so catalog membership by itself + * would answer `record.name.contains()` (a real receiver call with the wrong + * arity) with "write `contains(record.name)`", which faults just as hard. That + * class keeps the existing trailer: its fault is the ARGUMENTS, and grading + * those is the blind spot #13594 deliberately keeps blind. + */ +function receiverCallHint(celMessage: string, source: string): string | null { + const name = receiverCallNameFromNoOverload(celMessage); + if (!name || !CEL_STDLIB_FUNCTIONS.includes(name)) return null; + if (isReceiverRegistered(name)) return null; + const receiver = receiverChainInSource(source, name); + return ( + `\`${name}\` is callable bare, not as a method — a CALL-SHAPE fault, not a dialect ` + + `mistake, so re-spelling the expression will not fix it. ` + + (receiver + ? `Write \`${name}(${receiver})\` instead.` + : `Write \`${name}(…)\` with the receiver as its first argument 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.` + ); +} + function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void { if (!schema?.fields || schema.fields.length === 0) return; const known = new Set(schema.fields); @@ -499,15 +591,20 @@ export function validateExpression( // because the class is certain (it comes from the engine's own verdict) // while the braces hint is a heuristic. // + // The `type` class carries TWO prescriptions, and they are disjoint by + // construction rather than by ordering: #14203's receiver-call arm fires + // only when the name IS advertised, #13821's unknown-name arm only when it + // is not. Neither speaks for a fault it cannot name. + // // A `type` fault that names no unresolvable call — an operator or ternary // mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function - // handed wrong arguments — returns null from `unknownFunctionHint` and keeps - // the existing trailer: this arm has a name to hand back or it says nothing. + // handed wrong arguments (`upper(1, 2)`, `record.name.contains()`) — gets + // null from both and keeps the existing trailer. const classHint = compiled.error.kind === 'bounds' ? boundsHint(source) : compiled.error.kind === 'type' - ? unknownFunctionHint(compiled.error.message) + ? (receiverCallHint(compiled.error.message, source) ?? unknownFunctionHint(compiled.error.message)) : null; const hint = classHint ?? bracesHint(source); errors.push({