|
1 | 1 | import { describe, it, expect } from 'vitest'; |
2 | | -import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate'; |
| 2 | +import { |
| 3 | + validateExpression, |
| 4 | + introspectScope, |
| 5 | + expectedDialect, |
| 6 | + inferExpressionType, |
| 7 | + nearestName, |
| 8 | + CEL_STDLIB_FUNCTIONS, |
| 9 | +} from './validate'; |
3 | 10 | import { firstUndeclaredReference } from './cel-engine'; |
4 | 11 |
|
5 | 12 | describe('validateExpression (ADR-0032)', () => { |
@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => { |
199 | 206 | }); |
200 | 207 | }); |
201 | 208 |
|
| 209 | + // #13821 — the second leg of #7073. An unknown-function refusal is graded |
| 210 | + // `type` by the engine's own `check()`, so before this it fell through to |
| 211 | + // `bracesHint` (null, no brace) and out to the dialect trailer: "predicates |
| 212 | + // are bare CEL" handed to an author whose source already IS bare CEL and |
| 213 | + // parses fine. The guard itself was and stays correct — only the sentence the |
| 214 | + // author is told to act on was wrong. |
| 215 | + // |
| 216 | + // These assert the SPECIFIC prescription, not merely that an error fires; an |
| 217 | + // error already fired before the repair. And the `bounds` control below is |
| 218 | + // load-bearing: it proves a new class was routed rather than the shared tail |
| 219 | + // replaced for everyone. |
| 220 | + describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => { |
| 221 | + const dialectTrailer = (role: 'predicate' | 'value') => |
| 222 | + ` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`; |
| 223 | + |
| 224 | + it.each([ |
| 225 | + { name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' }, |
| 226 | + { name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' }, |
| 227 | + { name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' }, |
| 228 | + { name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' }, |
| 229 | + ])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => { |
| 230 | + const r = validateExpression('predicate', source); |
| 231 | + expect(r.ok).toBe(false); |
| 232 | + expect(r.errors).toHaveLength(1); |
| 233 | + const { message } = r.errors[0]; |
| 234 | + // The front half — cel-js's own vocabulary, matching the runtime fault — is kept. |
| 235 | + expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /); |
| 236 | + // The prescription NAMES the function that did not resolve… |
| 237 | + expect(message).toContain(`\`${fn}\` is not a callable name here`); |
| 238 | + expect(message).toMatch(/NAME fault, not a dialect mistake/); |
| 239 | + // …and points at the callable set, via the introspection API that publishes it. |
| 240 | + expect(message).toContain('`introspectScope`'); |
| 241 | + expect(message).toContain('`CEL_STDLIB_FUNCTIONS`'); |
| 242 | + // ⛔ The defect itself: the dialect trailer must NOT reach this class. |
| 243 | + expect(message).not.toContain(dialectTrailer('predicate')); |
| 244 | + expect(message).not.toMatch(/bare CEL/); |
| 245 | + }); |
| 246 | + |
| 247 | + it('applies to the `value` role too — one producer, all ~10 slots', () => { |
| 248 | + const r = validateExpression('value', 'nosuchfn(1)'); |
| 249 | + expect(r.ok).toBe(false); |
| 250 | + expect(r.errors[0].message).toMatch(/^invalid CEL value:/); |
| 251 | + expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here'); |
| 252 | + expect(r.errors[0].message).not.toContain(dialectTrailer('value')); |
| 253 | + }); |
| 254 | + |
| 255 | + // ⛔ The message may never state how many functions the catalog holds: what |
| 256 | + // the catalog contains is being adjudicated separately, and a sentence |
| 257 | + // asserting a count would be falsified by that ruling without failing here. |
| 258 | + it('refers to the callable set without asserting its size', () => { |
| 259 | + const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message; |
| 260 | + expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/); |
| 261 | + }); |
| 262 | + |
| 263 | + // did-you-mean is measured dangerous, so both directions are pinned. The |
| 264 | + // threshold is the whole safety argument; either case flipping is a |
| 265 | + // regression, not a tuning. |
| 266 | + describe('did-you-mean is thresholded (both measured cases pinned)', () => { |
| 267 | + it('suggests on a real typo: `isBlnk` → `isBlank`', () => { |
| 268 | + const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message; |
| 269 | + expect(message).toContain('`isBlnk` is not a callable name here'); |
| 270 | + expect(message).toContain('Did you mean `isBlank`?'); |
| 271 | + }); |
| 272 | + |
| 273 | + it('stays SILENT on a distant match: `can` must never be answered with `min`', () => { |
| 274 | + // `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a |
| 275 | + // three-character name, a jump from a permission verb to a numeric |
| 276 | + // function. Worse than silence: an author who takes it writes |
| 277 | + // `min(object, verb)`. |
| 278 | + const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message; |
| 279 | + expect(message).toContain('`can` is not a callable name here'); |
| 280 | + expect(message).not.toMatch(/Did you mean/); |
| 281 | + expect(message).not.toMatch(/`min`/); |
| 282 | + }); |
| 283 | + |
| 284 | + it('leaves the shared `nearestName` budget alone — this class narrows locally', () => { |
| 285 | + // The hazard is this catalog's, not the heuristic's: field-name |
| 286 | + // suggestions keep the shared budget. If this ever stops answering |
| 287 | + // `'min'`, the local threshold is no longer the thing protecting the |
| 288 | + // message and the pin above has quietly become vacuous. |
| 289 | + expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min'); |
| 290 | + expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank'); |
| 291 | + }); |
| 292 | + }); |
| 293 | + |
| 294 | + // The flipped pins. The refusal surface may never shrink, and this arm may |
| 295 | + // never speak for faults it cannot name. |
| 296 | + it('keeps the dialect trailer on a real function given arguments no overload accepts', () => { |
| 297 | + // Same cel-js message SHAPE, different fault: `upper` exists. Calling it |
| 298 | + // "not a callable name" would replace a useless sentence with a false one. |
| 299 | + const r = validateExpression('predicate', 'upper(1, 2)'); |
| 300 | + expect(r.ok).toBe(false); |
| 301 | + expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/); |
| 302 | + expect(r.errors[0].message).toContain(dialectTrailer('predicate')); |
| 303 | + expect(r.errors[0].message).not.toMatch(/NAME fault/); |
| 304 | + }); |
| 305 | + |
| 306 | + it.each([ |
| 307 | + { name: 'an operator type mismatch', source: "1 + 'a'" }, |
| 308 | + { name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" }, |
| 309 | + ])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => { |
| 310 | + const r = validateExpression('predicate', source); |
| 311 | + expect(r.ok).toBe(false); |
| 312 | + expect(r.errors[0].message).toContain(dialectTrailer('predicate')); |
| 313 | + expect(r.errors[0].message).not.toMatch(/NAME fault/); |
| 314 | + }); |
| 315 | + |
| 316 | + it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => { |
| 317 | + const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && '); |
| 318 | + const message = validateExpression('predicate', overBudget).errors[0].message; |
| 319 | + expect(message).toMatch(/SIZE fault, not a dialect mistake/); |
| 320 | + expect(message).toMatch(/Shrink it/); |
| 321 | + expect(message).not.toMatch(/NAME fault/); |
| 322 | + }); |
| 323 | + }); |
| 324 | + |
202 | 325 | describe('templates', () => { |
203 | 326 | it('accepts a valid {{ path }} template', () => { |
204 | 327 | const r = validateExpression('template', 'Hot lead: {{ record.full_name }}'); |
|
0 commit comments