From a4f4d1ab667bb7ebe4623d257b09561e2334aca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:41:19 +0000 Subject: [PATCH 1/2] feat(lint,formula): refuse a visibility predicate calling an unregistered CEL function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validate-visibility-predicates` was parse-only by design, so a predicate that parses perfectly and calls a function the CEL environment does not register passed the publish gate CLEAN — measured with two controls that fired (`country === "USA"` -> syntax, `status == 'active'` -> bare-identifier) while `totallyBogusFn(1,2)` and `record.x.nosuchmethod('a')` produced nothing. The runtime fault it hides falls OPEN on a view/page surface and CLOSED on an action surface, where the action disappears for every user including grant holders behind one deduped console.warn (objectui#4421). Maintainer ruling 2026-08-31 (director batch #21), on a censused premise (host-registered extra CEL functions in the reachable corpus = 0, positive control firing): extend the gate to report an unknown-function call as an ERROR. Scoped supersession of the parse-only ruling -- function existence only. - @objectstack/formula: new `firstUnknownFunctionCall(source)`. The oracle is the evaluation environment's own registration set, read through the same `buildEnv` seam `celEngine.compile` and `celEngine.evaluate` use -- never the advertised `CEL_STDLIB_FUNCTIONS` catalog, which lists 35 of the 72 registered names and would have refused 37 functions that evaluate today. The cel-js `found no matching overload for '...'` extraction moves here so this module and `validate.ts` cannot drift on which token was named. - @objectstack/lint: new `visibility-predicate-unknown-function` (error), covering the global and receiver/member call forms. Quotes the engine's own wording verbatim; offers no "did you mean" suggestion (nearest-name over the function namespace answers `min` for `can`). Everything else `check()` complains about stays unread: a registered name called with wrong arguments (`upper(1, 2)`), a registered name called in the wrong position (bare `split('a,b')`), the CEL-type blind spot (`type == 'grid'`) and operator-overload faults (`1 + 'a'`) are all still silent, and `type(record.x) == string` is untouched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...visibility-predicate-function-existence.md | 71 ++++++ packages/formula/src/index.ts | 12 + packages/formula/src/unknown-function.test.ts | 226 ++++++++++++++++++ packages/formula/src/unknown-function.ts | 179 ++++++++++++++ packages/formula/src/validate.ts | 21 +- packages/lint/src/index.ts | 1 + packages/lint/src/runtime-gate.test.ts | 22 ++ .../validate-visibility-predicates.test.ts | 203 +++++++++++++++- .../src/validate-visibility-predicates.ts | 199 +++++++++++++-- 9 files changed, 898 insertions(+), 36 deletions(-) create mode 100644 .changeset/visibility-predicate-function-existence.md create mode 100644 packages/formula/src/unknown-function.test.ts create mode 100644 packages/formula/src/unknown-function.ts diff --git a/.changeset/visibility-predicate-function-existence.md b/.changeset/visibility-predicate-function-existence.md new file mode 100644 index 0000000000..192235d181 --- /dev/null +++ b/.changeset/visibility-predicate-function-existence.md @@ -0,0 +1,71 @@ +--- +"@objectstack/formula": minor +"@objectstack/lint": minor +--- + +feat(lint,formula): refuse a visibility predicate that calls a function the CEL environment does not register (#13594) + +An accept-set narrowing at `objectstack validate` and at the runtime publish +door, ruled by the maintainer on 2026-08-31 (director batch #21) on a censused +premise. + +**The hole.** `validate-visibility-predicates` — the gate that judges +`visibleWhen` on view form sections/fields and page components — was +deliberately parse-only. So a predicate that parses perfectly and calls a +function that does not exist passed CLEAN, measured side by side with two +controls that fired: + +```text +source lint gate (before) validateExpression +totallyBogusFn(1,2) CLEAN ok=false +record.x.nosuchmethod('a') CLEAN ok=false +country === "USA" syntax ok=false <- control +status == 'active' bare-identifier ok=true <- control +``` + +The runtime fault it hides is the worst-shaped one the platform has: on a view +or page surface it falls OPEN (the element renders unconditionally, identical +to carrying no predicate at all), and on an action surface — evaluated with +`throwOnError: true` — it falls CLOSED, so the action disappears for every +user *including one who holds the grant*, behind a single deduped +`console.warn` (objectui#4421). A plausible-looking function name that does not +exist is exactly what a generator invents. + +**What changed.** + +- `@objectstack/formula` publishes `firstUnknownFunctionCall(source)` — the + function-EXISTENCE verdict, isolated from everything else cel-js's `check()` + has an opinion about. The oracle is the evaluation environment's own + registration set, read through the same `buildEnv` seam `celEngine.compile` + and `celEngine.evaluate` build with — never the advertised + `CEL_STDLIB_FUNCTIONS` catalog, which lists 35 of the 72 registered names and + would have refused 37 functions that resolve and evaluate today (`type`, + `map`, `filter`, `split`, `getFullYear`, `json`, …). +- `@objectstack/lint` gains `visibility-predicate-unknown-function` + (**error**), covering both call forms — global (`totallyBogusFn(1,2)`) and + receiver/member (`record.x.nosuchmethod('a')`). The message quotes the + engine's own `found no matching overload for '…'` verbatim so publish time + and run time read as one system, and offers **no** "did you mean" suggestion: + nearest-name matching over the function namespace was measured to answer + `min` for `can`. + +**Scoped supersession, not a widening.** The module's parse-only ruling stands +for everything except function existence. A registered name called with wrong +arguments (`upper(1, 2)`), a registered name called in the wrong position +(bare `split('a,b')`), the CEL-type blind spot (`type == 'grid'`) and every +operator-overload fault (`1 + 'a'`) are all still unreported — `type(record.x) +== string` and every other legal `dyn` predicate is untouched. Refusing an +unregistered call cannot be a false positive: the validation and runtime +environments are the same builder (53 probes, 0 divergence), so the call this +refuses is a call that would have faulted. + +**Migration.** A refused predicate names a function that does not exist and +never evaluated — replace it with an advertised callable, or precompute the +value into a formula field on the object and test that field. The census found +**0** host-registered extra CEL functions across the reachable corpus +(objectstack `packages/`/`examples/`/`apps/`, objectui, one shipped host app), +with a firing positive control, and this repo's `examples/**` and `apps/**` +sweep produces **0** new refusals. `cloud`, `objectos` and published +third-party apps were NOT MEASURED — if a host in one of those registers extra +CEL functions, its predicates are refused; that gap was declared before the +ruling and accepted with it. diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index c34a08e733..f68f71b3d2 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -78,6 +78,18 @@ export { __resetPushdownLimitWarnings } from './cel-to-filter'; // conditions ARE the red/green line. export { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate'; export { matchesFilterCondition } from './matches-filter'; +// #13594 — the function-EXISTENCE verdict, isolated from the rest of what +// cel-js's `check()` has an opinion about. Published for the same reason as +// `firstUndeclaredReference` above and under the same discipline: the answer to +// "does the environment register this name?" is the environment's to give, the +// environment is package-internal (`buildEnv`), and the alternative — a consumer +// keying a gate on the advertised `CEL_STDLIB_FUNCTIONS` catalog — was measured +// to refuse 37 names that resolve and evaluate today. `@objectstack/lint`'s +// view/page visibility gate is the first consumer (maintainer ruling, director +// batch #21, 2026-08-31). Existence ONLY: a registered name called with the +// wrong arguments, or in the wrong call position, is not reported here. +export { firstUnknownFunctionCall } from './unknown-function'; +export type { UnknownFunctionCall } from './unknown-function'; // ADR-0032 — shared validator + introspection (one validator for build, // registration, and the agent-callable validate_expression tool). export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate'; diff --git a/packages/formula/src/unknown-function.test.ts b/packages/formula/src/unknown-function.test.ts new file mode 100644 index 0000000000..3d7b547648 --- /dev/null +++ b/packages/formula/src/unknown-function.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { buildEnv, celEngine } from './cel-engine'; +import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function'; +import { CEL_STDLIB_FUNCTIONS } from './validate'; + +/** + * The function-existence oracle (#13594). + * + * The property under test is a BOUNDARY, not a feature: this oracle must answer + * "the environment does not register this name" and must stay silent about every + * other thing cel-js's `check()` has an opinion about. Both halves are pinned — + * the refusals AND the silences — because a widening here reaches an + * `error`-level publish gate (`@objectstack/lint`'s view/page visibility rule) + * where a false positive is a build nobody can ship. + * + * The registration set is MEASURED off `buildEnv` here rather than read back out + * of the module under test, for the reason `cel-stdlib-drift.test.ts` states + * about itself: a control that asks the implementation for its own oracle cannot + * catch the implementation getting the oracle wrong. + */ + +/** The instant the pinned environment is built at — any fixed instant will do. */ +const FIXED_NOW = () => new Date(0); + +function registeredNames(): string[] { + const env = buildEnv(FIXED_NOW, 'UTC') as unknown as { + getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> }; + }; + return [...new Set(env.getDefinitions().functions.map((fn) => fn.name))].sort(); +} + +function bareCallableNames(): Set { + const env = buildEnv(FIXED_NOW, 'UTC') as unknown as { + getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> }; + }; + return new Set(env.getDefinitions().functions.filter((fn) => !fn.receiverType).map((fn) => fn.name)); +} + +/** + * The engine's own verdict, used ONLY as a control — never as the subject. Taken + * from the real `celEngine` rather than a rebuilt lookalike, because a control + * measuring a different environment controls nothing. + */ +function celCompile(source: string) { + return celEngine.compile(source); +} + +describe('firstUnknownFunctionCall — what it REFUSES (#13594)', () => { + it('the global-call form names the function and quotes the engine verbatim', () => { + const found = firstUnknownFunctionCall('totallyBogusFn(1,2)'); + expect(found?.name).toBe('totallyBogusFn'); + // The engine's own wording, not a paraphrase — the publish-time message and + // the runtime fault have to read as one system (ruling refinement 2). + expect(found?.detail).toContain("found no matching overload for 'totallyBogusFn(int, int)'"); + }); + + it('the RECEIVER/member-call form is covered by the same verdict', () => { + // The half a global-only repair would have left open. cel-js phrases it with + // the receiver TYPE in front (`dyn.nosuchmethod(string)`); the method name is + // what the author typed and what has to come back. + const found = firstUnknownFunctionCall("record.x.nosuchmethod('a')"); + expect(found?.name).toBe('nosuchmethod'); + expect(found?.detail).toContain("found no matching overload for 'dyn.nosuchmethod(string)'"); + }); + + it('the objectui#4421 predicate — the authored shape this ruling came from', () => { + // `current_user` is a declared SCOPE_ROOT, so the unbound-root check cannot + // structurally see this one: existence is the only check that can. + expect(firstUnknownFunctionCall('current_user.can(object, verb)')?.name).toBe('can'); + }); + + it('a typo one edit away from a real function is still just unknown — no suggestion field', () => { + const found = firstUnknownFunctionCall('isBlnk(record.x)'); + expect(found?.name).toBe('isBlnk'); + // The result carries a name and the engine's line, and nothing else. Ruling + // refinement 2: 「不给 `nearestName` 建议。」 + expect(Object.keys(found ?? {}).sort()).toEqual(['detail', 'name']); + }); + + it('an unknown call inside a larger predicate is still found', () => { + expect(firstUnknownFunctionCall('record.amount > 100 && bogusFn2(record.x)')?.name).toBe('bogusFn2'); + }); +}); + +describe('firstUnknownFunctionCall — what it deliberately STAYS SILENT about', () => { + it.each([ + ["upper('a')", 'a registered function called correctly'], + ['type(record.x) == string', 'the legitimate CEL the blind-spot pin protects'], + ["record.tags.all(t, t != '')", 'a comprehension macro'], + ['record.items.exists(i, i.qty > 0)', 'a macro with a receiver'], + ['has(record.status)', 'the sparse-binding guard idiom'], + ["record.name.split(',')", 'a receiver-only stdlib method used correctly'], + ["record.created.getFullYear() > 2020", 'a receiver-only name absent from CEL_STDLIB_FUNCTIONS'], + ['size(record.tags) > 0', 'a cel-js built-in'], + ["status == 'active'", 'a bare identifier — a different gate’s verdict'], + ['record.x.foo.bar', 'a dotted path that is not a call at all'], + ])('%s → null (%s)', (source) => { + expect(firstUnknownFunctionCall(source)).toBeNull(); + }); + + it('a REGISTERED name called with the wrong arguments is not an existence fault', () => { + // cel-js gives this the SAME message shape as an unknown call + // (`found no matching overload for 'upper(int, int)'`), which is the entire + // reason this oracle exists rather than a regex over `compile`'s message. + // Ruling refinement 3: 「只拒未知函数裁定,⛔ 不搬运 `check()` 的其他抱怨。」 + expect(firstUnknownFunctionCall('upper(1, 2)')).toBeNull(); + // …and the control that the case is live: the message really is that shape. + expect(callNameFromNoOverload("found no matching overload for 'upper(int, int)'")).toBe('upper'); + }); + + it('a REGISTERED name called in the wrong POSITION is not an existence fault either', () => { + // `split` is registered receiver-only. Bare `split(...)` faults — but the + // name exists, so calling it "not registered" would be a false statement. + // Call-form is a different question and is not this gate's to answer. + expect(firstUnknownFunctionCall("split('a,b')")).toBeNull(); + expect(bareCallableNames().has('split')).toBe(false); + expect(registeredNames()).toContain('split'); + }); + + it.each([ + ["type == 'grid'", 'no such overload: type == string'], + ["1 + 'a'", 'no such overload: int + string'], + ])('a `type` fault phrased any other way is not read: %s', (source, expectedPhrasing) => { + expect(firstUnknownFunctionCall(source)).toBeNull(); + // Control: the source really IS refused by the checker, so the null above is + // this oracle standing down rather than the checker finding nothing. + const compiled = celCompile(source); + expect(compiled.ok).toBe(false); + expect(compiled.ok ? '' : compiled.error.message).toContain(expectedPhrasing); + }); + + it.each([ + ['country === "USA"', 'a parse fault — the syntax rule owns it'], + ['', 'an empty source'], + [' ', 'a whitespace-only source'], + ])('%s → null (%s)', (source) => { + expect(firstUnknownFunctionCall(source)).toBeNull(); + }); + + it('an over-budget but valid source is a bounds fault, not an existence one', () => { + const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && '); + expect(firstUnknownFunctionCall(overBudget)).toBeNull(); + const compiled = celCompile(overBudget); + expect(compiled.ok ? '' : compiled.error.kind).toBe('bounds'); + }); +}); + +describe('the oracle is the ENVIRONMENT, never the advertised catalog (refinement 1)', () => { + it('no registered name is ever reported unknown — the full-registration control', () => { + // The measured hazard the ruling names: a gate keyed on CEL_STDLIB_FUNCTIONS + // would refuse the 37 names the environment registers but does not + // advertise. Every registered name is probed in BOTH call forms, and the + // assertion is the narrow one — the oracle may say nothing, and may not say + // "this name does not exist". + const names = registeredNames(); + expect(names.length, 'the environment registered nothing — the seam is broken, not the oracle') + .toBeGreaterThan(35); + + const misjudged: string[] = []; + for (const name of names) { + for (const source of [`${name}(record.x)`, `record.x.${name}(record.y)`]) { + if (firstUnknownFunctionCall(source)?.name === name) misjudged.push(source); + } + } + expect(misjudged, 'registered functions reported as unknown — these authored ' + + 'predicates would be refused at the publish gate').toEqual([]); + }); + + it('the registered-but-not-advertised gap is real and is entirely accepted', () => { + // Derived from the two sets rather than transcribed, so the control tracks a + // cel-js upgrade instead of pinning yesterday's census. If this ever emptied, + // the test above would silently stop covering the hazard it exists for. + const advertised = new Set(CEL_STDLIB_FUNCTIONS); + const unadvertised = registeredNames().filter((name) => !advertised.has(name)); + expect(unadvertised.length, + 'no registered-but-unadvertised names left — the catalog-as-oracle hazard ' + + 'this control covers has gone, and so has the control').toBeGreaterThan(0); + for (const name of unadvertised) { + expect(firstUnknownFunctionCall(`${name}(record.x)`)?.name).not.toBe(name); + expect(firstUnknownFunctionCall(`record.x.${name}(record.y)`)?.name).not.toBe(name); + } + }); + + it('a name absent from BOTH sets is what actually gets refused', () => { + // The negative control for the two above: the probe shape they use does + // produce a refusal when the name really is unregistered, so their silence + // is a verdict rather than a broken probe. + expect(registeredNames()).not.toContain('definitelyNotRegistered'); + expect(firstUnknownFunctionCall('definitelyNotRegistered(record.x)')?.name) + .toBe('definitelyNotRegistered'); + expect(firstUnknownFunctionCall('record.x.definitelyNotRegistered(record.y)')?.name) + .toBe('definitelyNotRegistered'); + }); +}); + +describe('callNameFromNoOverload — the shared extraction (#13594)', () => { + it.each([ + ["found no matching overload for 'totallyBogusFn(int, int)'", 'totallyBogusFn'], + ["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'], + ["found no matching overload for 'upper(int, int)'", 'upper'], + ])('%s → %s', (message, expected) => { + expect(callNameFromNoOverload(message)).toBe(expected); + }); + + it.each([ + 'no such overload: type == string', + 'no such overload: int + string', + 'Unknown variable: status', + ])('is undefined for a message that is not a call verdict: %s', (message) => { + expect(callNameFromNoOverload(message)).toBeUndefined(); + }); + + it('does not run past the call into the source excerpt cel-js appends', () => { + // cel-js's `formatErrorWithHighlight` puts the author's own source on the + // following lines, dots and all. The greedy receiver prefix must not reach it. + const message = + "found no matching overload for 'dyn.nosuchmethod(string)'\n" + + " record.a.b.c('x')\n" + + ' ^'; + expect(callNameFromNoOverload(message)).toBe('nosuchmethod'); + }); +}); + diff --git a/packages/formula/src/unknown-function.ts b/packages/formula/src/unknown-function.ts new file mode 100644 index 0000000000..1da7806c2a --- /dev/null +++ b/packages/formula/src/unknown-function.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The **function-existence** verdict, isolated from every other thing + * `check()` has an opinion about (#13594). + * + * `celEngine.compile` already answers "does this source call something that + * resolves?" — it reads cel-js's `check()` and reports `kind: 'type'` when the + * answer is no (#1877). That verdict is what `validateExpression` refuses on, + * and it is the ONE part of the type checker a gate may adopt without adopting + * the rest: an unresolvable call is a fact about the source, decidable at + * authoring time, while everything else `check()` complains about on this + * platform's surfaces is a fact about a `dyn` value it cannot see yet. + * + * ## Why a consumer needs this instead of calling `compile` itself + * + * cel-js emits ONE message shape for two different faults: + * + * ```text + * totallyBogusFn(1,2) -> found no matching overload for 'totallyBogusFn(int, int)' + * upper(1, 2) -> found no matching overload for 'upper(int, int)' + * ``` + * + * The first names something the environment does not have. The second names + * `upper`, which it does have — the call is merely wrong about the argument + * types, which under `unlistedVariablesAreDyn` is usually a fact about a row + * rather than about the source. A consumer that refused both would be running + * the type checker, which is exactly what the ruling below does NOT authorise. + * Separating them needs the environment's own registration set, and that + * environment is package-internal (`buildEnv`), so the separation belongs here + * rather than in every consumer. + * + * ## The oracle is the environment, never `CEL_STDLIB_FUNCTIONS` + * + * Maintainer ruling on #13594 (director batch #21, 2026-08-31), refinement 1: + * + * > 「oracle = 引擎实际注册集(cel-js `check()` 裁定),⛔ 不是 `CEL_STDLIB_FUNCTIONS` 常量。」 + * + * The exported catalog advertises 35 bare-callable names for authoring; the + * environment registers **72** (measured — `cel-stdlib-drift.test.ts` re-measures + * the decomposition on every run). A gate keyed on the catalog would refuse 37 + * names that parse, type-check and evaluate today (`type`, `map`, `filter`, + * `split`, `getFullYear`, `json`, …). Existence is the environment's answer to + * give, and it is given here through the same `buildEnv` seam `compile` builds + * its checker with, so the two can never drift. + * + * Both call forms are covered by the one verdict, because cel-js phrases them + * 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. + * + * ## What this deliberately does NOT report + * + * Refinement 3 of the same ruling: 「只拒未知函数裁定,⛔ 不搬运 `check()` 的其他抱怨。」 + * + * - **A registered name called wrongly.** `upper(1, 2)`, and every arity or + * argument-type fault on a name the environment has. Registered is registered. + * - **A registered name called in the wrong POSITION.** `split('a,b')` faults — + * `split` is registered receiver-only — but the name exists, so this is a + * call-form fault, not an existence one. The membership set is therefore + * every registered name, bare-callable and receiver-only alike; narrowing it + * to the bare-callable 39 would turn `record.name.split(',')`-shaped authoring + * mistakes into existence claims that are false. + * - **Everything phrased any other way.** `type == 'grid'` is refused as + * `no such overload: type == string` and `1 + 'a'` as `no such overload: + * int + string`; neither matches the call template, so neither is reported. + * That is the CEL-type blind spot staying blind, which is the property + * refinement 3 protects. + * + * ## Why refusing an unregistered call is not a false positive + * + * The validation environment and the runtime environment are the same builder — + * `celEngine.compile` and `celEngine.evaluate` both call {@link buildEnv}, and + * the census on #13594 probed 53 names across both with **0 divergent verdicts** + * (and found **0** host-registered extra functions in the reachable corpus). So + * a call this refuses is a call that WILL fault at evaluation time; refusing it + * moves the fault from an invisible runtime moment to the publish gate. + */ + +import { buildEnv, celEngine } from './cel-engine'; + +/** + * cel-js's unknown-call vocabulary, both of its spellings — a bare call + * (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a + * receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from + * one template family in `cel-js/lib/operators.js`, and the name we want is the + * segment immediately before the argument list, after any receiver-type prefix. + * + * Anchored on the closing `)'` so the greedy receiver prefix cannot run past the + * call into the source excerpt cel-js appends on the following lines. + * + * Lives here rather than in `validate.ts` — which reads the same message for its + * unknown-name hint — so the two readers cannot drift into two answers about + * which token cel-js was talking about. + */ +const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/; + +/** + * The called name inside a cel-js `found no matching overload for '…'` message, + * or `undefined` when the message is not that shape. + * + * Says nothing about whether the name exists — that is + * {@link firstUnknownFunctionCall}'s question, and `validate.ts` asks its own + * (is the name ADVERTISED?) for a different purpose. This only extracts. + */ +export function callNameFromNoOverload(message: string): string | undefined { + return 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. + * + * Read through `getDefinitions()` off {@link buildEnv}, the same constructor + * `celEngine.compile` and `celEngine.evaluate` use, for the reason + * `cel-stdlib-drift.test.ts` states about itself: a set rebuilt from a lookalike + * environment keeps answering after the real one changes underneath it. + * + * Memoised because the answer cannot vary — the two arguments `buildEnv` takes + * (a `now()` closure and a timezone) change what `now()` RETURNS, never which + * 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; + +function registeredFunctionNames(): ReadonlySet { + if (!registeredNames) { + const env = buildEnv(() => new Date(0)) as unknown as { + getDefinitions(): { functions: Array<{ name: string }> }; + }; + registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name)); + } + return registeredNames; +} + +/** 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. */ + name: string; + /** + * The engine's own one-line verdict, quoted rather than paraphrased + * (`found no matching overload for 'totallyBogusFn(int, int)'`). Consumers + * report this verbatim so the publish-time wording and the runtime fault read + * as one system. + */ + detail: string; +} + +/** + * The first call in `source` naming a function the evaluation environment does + * not register, or `null` when there is none. + * + * `null` is the answer for every other outcome as well — a source that parses + * and type-checks, one the front end refuses for syntax or size, and one + * `check()` rejects for any reason that is not an unresolvable call. A caller + * gets an existence verdict or nothing; it never has to grade a fault itself. + * + * Deliberately offers **no suggestion**. Ruling refinement 2: + * 「不给 `nearestName` 建议。」 — `nearestName('can', )` + * answers `'min'`, a confident jump from a permission verb to a numeric + * function, and an author who takes it (an LLM author above all, following the + * last sentence it was handed) is further from working than before it asked. + */ +export function firstUnknownFunctionCall(source: string): UnknownFunctionCall | null { + if (!source.trim()) return null; + const compiled = celEngine.compile(source); + // Parses and type-checks, or was refused for something that is not a call: + // `parse` (not CEL), `bounds` (too big), `runtime` (never reachable from + // `compile`). Only the `type` arm can carry the verdict this asks for. + if (compiled.ok || compiled.error.kind !== 'type') return null; + const name = callNameFromNoOverload(compiled.error.message); + // A `type` fault phrased any other way — an operator or ternary mismatch + // (`no such overload: int + string`). Not an existence question. + 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; + return { name, detail: compiled.error.message.split('\n')[0].trim() }; +} diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index c23c151f6f..f1df9d9db4 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -26,6 +26,13 @@ import { type FieldCelType, } from './cel-engine'; import { templateEngine } from './template-engine'; +// #13594 — the one reader of cel-js's `found no matching overload for '…'` +// template. Both this module (which asks whether the name is ADVERTISED, to word +// a hint) and `firstUnknownFunctionCall` (which asks whether the environment +// 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'; export type FieldRole = 'predicate' | 'value' | 'template'; @@ -254,18 +261,6 @@ function boundsHint(source: string): string | null { ); } -/** - * cel-js's unknown-call vocabulary, both of its spellings — a bare call - * (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a - * receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from - * one template family in `cel-js/lib/operators.js`, and the name we want is the - * segment immediately before the argument list, after any receiver-type prefix. - * - * Anchored on the closing `)'` so the greedy receiver prefix cannot run past the - * call into the source excerpt cel-js appends on the following lines. - */ -const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/; - /** * The nearest advertised callable to `name`, or `undefined` when nothing is * close enough that a suggestion beats silence. @@ -340,7 +335,7 @@ function nearestCallable(name: string): string | undefined { * would be falsified by that ruling. */ function unknownFunctionHint(celMessage: string): string | null { - const name = NO_OVERLOAD_RE.exec(celMessage)?.[1]; + const name = callNameFromNoOverload(celMessage); if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null; const suggestion = nearestCallable(name); return ( diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index f76e13717e..9cfeda2e86 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -223,6 +223,7 @@ export { VISIBILITY_BARE_IDENTIFIER, VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_OVER_BUDGET, + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, } from './validate-visibility-predicates.js'; export type { VisibilityFinding, diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 1eb990434a..5f3e4815b4 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -377,6 +377,28 @@ describe('the views[] visibility-predicate family at the runtime publish gate (# expect(f!.severity).toBe('error'); }); + it('REFUSES a predicate calling a function the CEL environment does not register', () => { + // #13594 — the arm that used to walk through. `current_user.can(object, verb)` + // is the authored shape from objectui#4421: it PARSES, so the syntax arm has + // nothing to say, and `current_user` is a declared root, so the bare-ref arm + // cannot reach the call. Before the ruling this reached a tenant's runtime + // and faulted fail-CLOSED on every surface. + const { errors } = gateView(runtimeView('current_user.can(object, verb)')); + const f = errors.find((e) => e.rule === 'visibility-predicate-unknown-function'); + expect(f, 'the publish door must refuse an unresolvable call, not just the validator').toBeDefined(); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); + expect(f!.message).toMatch(/`can`/); + expect(f!.message).toMatch(/found no matching overload/); + }); + + it('ACCEPTS a predicate whose functions all resolve — the control for the arm above', () => { + // Same door, same shape, a registered function. Without this the test above + // would still pass if the arm had started refusing every call. + const { errors } = gateView(runtimeView("upper(record.name) == 'X'")); + expect(errors.map((e) => e.rule)).not.toContain('visibility-predicate-unknown-function'); + }); + it('REFUSES a predicate path the target schema does not declare', () => { const { errors } = gateView(schemaBoundForm("data.tpye == 'text'")); const f = errors.find((e) => e.rule === 'predicate-path-unresolved'); diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index b4aee41842..618b6f4b8a 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -11,8 +11,10 @@ import { VISIBILITY_BARE_IDENTIFIER, VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_OVER_BUDGET, + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, } from './validate-visibility-predicates.js'; import { AUTHORING_RULES } from './authoring-rules.js'; +import { CEL_STDLIB_FUNCTIONS } from '@objectstack/formula'; describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { it('is clean for canonical `visibleWhen` with a runtime binding root', () => { @@ -895,17 +897,31 @@ describe('visibility-predicate-syntax (#6253)', () => { .toEqual([VISIBILITY_BARE_IDENTIFIER]); }); - it('does NOT widen to type-checking — the CEL-type blind spot stays a blind spot', () => { + it('does NOT widen to type-checking — the CEL-type blind spot stays blind, function existence excepted', () => { // `type == 'grid'` PARSES; only `celEngine.compile`'s type checker rejects // it (`no such overload: type == string`). Routing this rule through // `compile` / `validateExpression` would silently overturn the deliberate, // separately-pinned decision to stay conservative there — and would widen // an error-level gate from "does not parse" to "does not type-check" on a - // surface whose predicates are overwhelmingly `dyn`. The ruling said - // syntax; the parse verdict is exactly syntax. + // surface whose predicates are overwhelmingly `dyn`. expect(validateVisibilityPredicates(formStack("type == 'grid'"))).toEqual([]); // The legitimate CEL the overload message cannot be told apart from. expect(validateVisibilityPredicates(formStack('type(record.x) == string'))).toEqual([]); + // A registered function called with arguments no overload accepts is the + // same class and stays blind too — cel-js phrases it identically to an + // unknown call, and telling this author their function does not exist + // would be a fresh false statement. + expect(validateVisibilityPredicates(formStack('upper(1, 2)'))).toEqual([]); + + // ── #13594 narrows this pin by exactly one axis ────────────────── + // + // The maintainer's 2026-08-31 ruling is a SCOPED supersession of the + // parse-only decision quoted above: a call to a name the environment does + // not register is now an error, and nothing else about the type checker + // is read. Updated here as part of the ruling rather than silently, so + // the narrowing is visible where the old absolute claim used to be. + expect(validateVisibilityPredicates(formStack('totallyBogusFn(1,2)')).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_UNKNOWN_FUNCTION]); }); it('a `DEFAULT_LIMITS` overrun is NOT this rule any more — it is `over-budget` (#7217)', () => { @@ -1027,6 +1043,185 @@ const OVER_DEPTH = `${'('.repeat(60)}record.a${')'.repeat(60)} == 1`; /** 200-element list literal — `maxListElements`. */ const OVER_LIST = `record.id in [${Array.from({ length: 200 }, (_, i) => `'u${i}'`).join(',')}]`; +/** Only the unknown-function findings (#13594). */ +function unknownFnFindings(stack: Record, opts?: { layer: 'runtime' | 'metadata' }) { + return validateVisibilityPredicates(stack, opts) + .filter((f) => f.rule === VISIBILITY_PREDICATE_UNKNOWN_FUNCTION); +} + +/** + * `visibility-predicate-unknown-function` (#13594) — the scoped supersession of + * this file's parse-only ruling. + * + * The card's own three probes are the acceptance pair's backbone, because they + * are what was MEASURED clean here while `validateExpression` refused all three: + * + * ```text + * source this gate (before) validateExpression + * totallyBogusFn(1,2) CLEAN ok=false + * record.x.nosuchmethod('a') CLEAN ok=false + * upper('a') CLEAN ok=true <- must STAY clean + * ``` + * + * The third row is not decoration: it is the control that separates "the gate + * now refuses unknown calls" from "the gate now refuses calls". + */ +describe('visibility-predicate-unknown-function (#13594)', () => { + describe('the acceptance pair', () => { + it('the GLOBAL-call form is an ERROR that names the function and quotes the engine', () => { + const findings = validateVisibilityPredicates(formStack('totallyBogusFn(1,2)')); + + // The WHOLE reported set — so "one rule, not also the bare-ref rule" is + // pinned here rather than assumed. + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_UNKNOWN_FUNCTION]); + // `error`, per the ruling: a WARNING would swap a silent failure for a + // notice nobody reads. + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].sections[0].fields[0]'); + expect(findings[0].message).toContain('`totallyBogusFn`'); + // The engine's own vocabulary, verbatim — the same sentence the runtime + // fault produces, so publish time and run time read as one system. + expect(findings[0].message) + .toContain("found no matching overload for 'totallyBogusFn(int, int)'"); + }); + + it('the RECEIVER/member-call form is the same ERROR — half a repair is not a repair', () => { + const findings = unknownFnFindings(formStack("record.x.nosuchmethod('a')")); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + // The METHOD name, not the receiver type cel-js prefixes it with. + expect(findings[0].message).toContain('`nosuchmethod`'); + expect(findings[0].message).not.toContain('`dyn`'); + }); + + it("a registered function stays clean — `upper('a')` is the control that makes the pair a pair", () => { + expect(validateVisibilityPredicates(formStack("upper('a')"))).toEqual([]); + }); + }); + + it('the objectui#4421 predicate is refused, and the call is what names it', () => { + // The authored predicate this ruling came from. `current_user` is a declared + // SCOPE_ROOT, so the bare-identifier rule cannot structurally reach `can` — + // it reports the rootless ARGUMENTS instead. Both findings are real and have + // different fixes, which is why they are not mutually exclusive. + const rules = validateVisibilityPredicates(formStack('current_user.can(object, verb)')) + .map((f) => f.rule); + expect(rules).toContain(VISIBILITY_PREDICATE_UNKNOWN_FUNCTION); + expect(rules).toContain(VISIBILITY_BARE_IDENTIFIER); + expect(unknownFnFindings(formStack('current_user.can(object, verb)'))[0].message) + .toContain('`can`'); + }); + + it('⛔ offers no "did you mean" suggestion, however close the typo (refinement 2)', () => { + // `nearestName('can', )` answers `min`. The ruling ships + // the engine's own wording and nothing on top of it, so a one-edit typo gets + // the same treatment as a wholly invented name. + const findings = unknownFnFindings(formStack('isBlnk(record.x)')); + expect(findings).toHaveLength(1); + expect(findings[0].message).not.toMatch(/did you mean/i); + expect(findings[0].hint).not.toMatch(/did you mean/i); + // …and the hazard itself, stated as a case: nothing anywhere in the finding + // proposes `isBlank` (or, for `can`, `min`). + expect(findings[0].message).not.toContain('`isBlank`'); + expect(findings[0].hint).not.toContain('`isBlank`'); + expect(JSON.stringify(unknownFnFindings(formStack('can(record.x)')))).not.toContain('`min`'); + }); + + it('the hint says NAME fault, not dialect — the #7073 / #13821 correction, kept', () => { + // The source is already bare CEL and parses, so the dialect prescription + // ("write `==` not `===`") is advice that cannot succeed. An author who + // follows the last sentence they were handed comes back with the same + // unresolvable name. + const hint = unknownFnFindings(formStack('totallyBogusFn(1,2)'))[0].hint; + expect(hint).toContain('NAME fault'); + expect(hint).not.toContain('`===`'); + expect(hint).toContain('CEL_STDLIB_FUNCTIONS'); + }); + + it('every carrier the schema declares reaches the rule', () => { + const section = { views: [{ name: 'f', sections: [{ visibleWhen: 'bogusFn(1)', fields: [] }] }] }; + expect(unknownFnFindings(section).map((f) => f.path)).toEqual(['views[0].sections[0]']); + + const page = { pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: 'bogusFn(1)' }] }] }] }; + expect(unknownFnFindings(page).map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']); + + // The deprecated alias VALUE is still read (#6318 retired the KEY rule only). + const alias = { views: [{ name: 'f', sections: [{ visibleOn: 'bogusFn(1)', fields: [] }] }] }; + expect(unknownFnFindings(alias)).toHaveLength(1); + }); + + it('the verdict is layer-agnostic — the environment registers the same names on both', () => { + // Unlike the bare-identifier and mis-layered rules, nothing here depends on + // which root the surface binds, so both layers must answer identically. + expect(unknownFnFindings(formStack('bogusFn(1)'), { layer: 'runtime' })).toHaveLength(1); + expect(unknownFnFindings(formStack('bogusFn(1)'), { layer: 'metadata' })).toHaveLength(1); + }); + + describe('the boundaries — what this rule must never widen into', () => { + it.each([ + ["upper('a')", 'a registered function, called correctly'], + ['upper(1, 2)', 'a registered function, WRONG arguments — a type fault, not existence'], + ["split('a,b')", 'a registered receiver-only name called bare — a call-FORM fault'], + ["record.name.split(',')", 'the same name used correctly'], + ['record.created.getFullYear() > 2020', 'a registered name absent from CEL_STDLIB_FUNCTIONS'], + ['type(record.x) == string', 'the legitimate CEL the blind-spot pin protects'], + ["type == 'grid'", 'the CEL-type blind spot itself'], + ["1 + 'a'", 'an operator overload fault — no call in it at all'], + ["record.tags.all(t, t != '')", 'a comprehension macro'], + ['has(record.status)', 'the sparse-binding guard idiom'], + ['size(record.tags) > 0', 'a cel-js built-in'], + ["record.type in ['lookup', 'master_detail']", 'a membership test'], + ])('%s produces no unknown-function finding (%s)', (predicate) => { + expect(unknownFnFindings(formStack(predicate))).toEqual([]); + }); + + it('an unparseable source is the SYNTAX rule, never this one', () => { + // No type verdict exists for a source the front end refused, and the two + // ids must not both fire on one predicate. + expect(validateVisibilityPredicates(formStack('country === "USA"')).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_SYNTAX]); + }); + + it('an over-budget source is the OVER-BUDGET rule, never this one', () => { + expect(validateVisibilityPredicates(formStack(OVER_AST_NODES)).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_OVER_BUDGET]); + }); + + it.each([[undefined], [' '], ['']])('an absent / blank predicate is not a fault: %s', (predicate) => { + expect(validateVisibilityPredicates(formStack(predicate))).toEqual([]); + }); + }); + + it('two real defects in one predicate produce two findings, not one silence', () => { + // The one-finding property this file used to state flatly is restated per + // RULE PAIR: syntax/over-budget and bare-identifier are still mutually + // exclusive, but an unknown CALL and a rootless VALUE are different tokens + // with different fixes, and an author told about only one comes back with + // the other. + const rules = validateVisibilityPredicates(formStack('bogusFn(status)')).map((f) => f.rule); + expect(rules).toEqual([VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, VISIBILITY_BARE_IDENTIFIER]); + }); + + it('registered names are not refused — the catalog-as-oracle hazard, at the gate (refinement 1)', () => { + // `CEL_STDLIB_FUNCTIONS` advertises 35 names; the environment registers 72. + // A gate keyed on the catalog would refuse the 37-name gap. The exhaustive + // 72-name control lives in `@objectstack/formula`'s `unknown-function.test.ts`, + // where the environment is reachable; this is the wiring half — a sample + // drawn from the gap, each name measured to resolve today. + const registeredButUnadvertised = [ + 'type', 'map', 'filter', 'split', 'getFullYear', 'json', 'substring', 'indexOf', + 'lowerAscii', 'exists_one', 'hasValue', 'orValue', + ]; + for (const name of registeredButUnadvertised) { + expect(CEL_STDLIB_FUNCTIONS, `${name} would make this row vacuous`).not.toContain(name); + const findings = unknownFnFindings(formStack(`record.x.${name}(record.y)`)); + expect(findings.map((f) => f.message), `${name} was refused as unknown`).toEqual([]); + } + // Non-vacuous: the same probe SHAPE refuses a name that really is absent. + expect(unknownFnFindings(formStack('record.x.notARegisteredName(record.y)'))).toHaveLength(1); + }); +}); + describe('visibility-predicate-over-budget (#7217)', () => { describe('the acceptance pair', () => { it('an over-budget but valid predicate names the SIZE fault and the bound, never the dialect', () => { @@ -1221,6 +1416,8 @@ describe('emitted prose names the surface, not a source file (#8042)', () => { ['syntax · metadata', publishedForm('country === "USA"')], ['over-budget · runtime', formStack(OVER_AST_NODES)], ['over-budget · metadata', publishedForm(OVER_AST_NODES)], + ['unknown-function · runtime', formStack('totallyBogusFn(1,2)')], + ['unknown-function · metadata', publishedForm('totallyBogusFn(1,2)')], ]; for (const [name, stack] of cases) { const findings = validateVisibilityPredicates(stack); diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 8d0479c6f5..e51664b6b5 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -53,7 +53,7 @@ * normalized tier, and none was affected by the retirement. * * One advisory rule (`warning` — nothing is broken, a mis-rooted predicate just - * never matches) plus THREE **gating** rules (`error` — the predicate can never + * never matches) plus FOUR **gating** rules (`error` — the predicate can never * evaluate at all): * * - `visibility-predicate-syntax` (**error**, #6253) — a predicate the canonical @@ -65,6 +65,12 @@ * `DEFAULT_LIMITS` parse bound (`maxAstNodes` 256, `maxDepth` 32, …). Same * verdict, same severity, different EDIT — see §Syntax for why that is worth * its own id. + * - `visibility-predicate-unknown-function` (**error**, #13594) — a predicate + * that PARSES perfectly and calls a function the CEL environment does not + * register (`current_user.can(object, verb)`, `totallyBogusFn(1,2)`). See the + * §Function existence block below for the ruling that put it here, for why it + * is the one `check()` verdict this file adopts, and for the four things it + * deliberately still does not report. * - `visibility-bare-identifier` (**error**, #6128 / #5149 requirement 3) — a * predicate referencing a top-level identifier that no binding root can * resolve (`status == 'active'` instead of `record.status == 'active'`). See @@ -138,16 +144,97 @@ * it. {@link NON_CEL_SPELLINGS} supplies that, and cannot change any verdict — * it is consulted only after the parse has already failed. * - * Deliberately NOT `validateExpression` / `celEngine.compile`, though those are - * the ADR-0032 entries and the temptation is obvious. `compile()` is parse **+ - * type-check**, and the difference is not theoretical: measured, it rejects - * `type == 'grid'` with `no such overload: type == string`. That shape is this - * file's pinned blind spot (see the CEL-TYPE bullet below) — a deliberate, - * test-documented decision to stay conservative — and routing this rule through - * `compile()` would silently overturn it from the syntax branch, widening an - * `error`-level gate from "does not parse" to "does not type-check" on a surface - * whose predicates are overwhelmingly `dyn`. The ruling says syntax; the parse - * verdict is exactly syntax. + * The SYNTAX rule is still deliberately not routed through `validateExpression` + * / `celEngine.compile`, and the reason is the one this file has always given — + * quoted here as it stood, because #13594 supersedes exactly one clause of it + * and leaves the rest standing: + * + * > Deliberately NOT `validateExpression` / `celEngine.compile`, though those + * > are the ADR-0032 entries and the temptation is obvious. `compile()` is parse + * > **+ type-check**, and the difference is not theoretical: measured, it + * > rejects `type == 'grid'` with `no such overload: type == string`. That shape + * > is this file's pinned blind spot (see the CEL-TYPE bullet below) — a + * > deliberate, test-documented decision to stay conservative — and routing this + * > rule through `compile()` would silently overturn it from the syntax branch, + * > widening an `error`-level gate from "does not parse" to "does not + * > type-check" on a surface whose predicates are overwhelmingly `dyn`. The + * > ruling says syntax; the parse verdict is exactly syntax. + * + * Every sentence there is still true, and `visibility-predicate-syntax` still + * gives the parse verdict and only the parse verdict. What changed is that ONE + * verdict inside `compile()` turned out not to be a type-check at all — see + * §Function existence. + * + * ## Function existence — the one `check()` verdict this file adopts (#13594) + * + * ### The evidence that reopened it + * + * objectui#4421: an authored `current_user.can(object, verb)` predicate passed + * this gate CLEAN and then faulted at runtime on every surface. Measured + * side-by-side, with controls, before the ruling: + * + * ```text + * source this gate validateExpression + * totallyBogusFn(1,2) CLEAN ok=false + * record.x.nosuchmethod('a') CLEAN ok=false + * country === "USA" syntax ok=false <- control: gate reached + * status == 'active' bare-ident ok=true <- control: gate reached + * ``` + * + * Two rows that FIRE are what make the two CLEAN rows a reading rather than a + * blank run. The card was filed against `validateExpression`; that premise was + * falsified twice over (published artifact and working tree) — the ADR-0032 + * validator has refused unknown calls since #1877 — and the hole was here, on + * the one predicate surface `validate-expressions.ts` does not walk. + * + * ### The ruling + * + * Maintainer, 2026-08-31, on a censused premise (host-registered extra CEL + * functions in the reachable corpus = **0**, positive control firing): this gate + * is EXTENDED to report an unknown-function call as an `error`. It is a **scoped + * supersession** of the parse-only ruling quoted above — superseded for function + * existence, and for nothing else. The execution comment carries six refinements; + * three of them are the boundaries below, verbatim. + * + * ### Why this is not the widening the old ruling forbade + * + * 1. **The oracle is the environment, not a curated list.** + * 「oracle = 引擎实际注册集(cel-js `check()` 裁定),⛔ 不是 `CEL_STDLIB_FUNCTIONS` 常量。」 + * The catalog advertises 35 names; the environment registers 72. A gate keyed + * on the catalog would refuse 37 functions that evaluate today — which is why + * this file asks `@objectstack/formula`'s `firstUnknownFunctionCall` and never + * reads a name list of its own. Same discipline as `firstUndeclaredReference` + * and `parseCelToAst`: the verdict is never ours (#4812). + * 2. **Existence is not type-checking.** + * 「只拒未知函数裁定,⛔ 不搬运 `check()` 的其他抱怨。」 A `dyn` predicate is + * refused only when it NAMES something that does not exist. `type(record.x) + * == string` still passes, `type == 'grid'` is still a blind spot, `upper(1, + * 2)` is still not our business, and `1 + 'a'` is still nobody's. The blind + * spot narrowed by exactly one axis and is pinned that way. + * 3. **It cannot be a false positive.** The validation environment and the + * runtime environment are the same builder (53 probes, 0 divergence), so a + * call this refuses is a call that WILL fault when evaluated. The old ruling's + * fear — an `error`-level gate rejecting predicates that work — needs a fault + * class that is data-dependent, and existence is not one. + * 4. **No suggestion is offered.** 「不给 `nearestName` 建议。」 — + * `nearestName('can', )` answers `'min'`. The engine's own + * wording ships verbatim and nothing is guessed on top of it. + * + * The fault this closes is the one metadata validation exists for, and the one + * an AI author hits hardest: a plausible-looking function name that does not + * exist is exactly what a generator invents, and on an action surface the + * consequence is fail-CLOSED and nearly silent — the action vanishes for every + * user, grant-holders included, behind one deduped `console.warn`. + * + * ### NOT MEASURED + * + * The census that supplied the premise reached objectstack, objectui and one + * shipped host app. `cloud` and `objectos` — the two repos that carry host + * configuration — and published third-party apps were unreachable, and objectui's + * own template-function registry (`FormulaFunctions`) is a different engine + * outside this gate's jurisdiction. If a host in one of those registers extra CEL + * functions, its predicates are refused by this rule. That gap was declared to + * the maintainer before the ruling and accepted with it. * * ### The refusal is one verdict and TWO edits (#7217) * @@ -262,6 +349,15 @@ * measured blind spot in the safe direction — a missed catch, never a false * build error — pinned by a test so it reads as a decision. * + * ⚠️ **Function existence excepted (#13594).** The pin used to read "the + * CEL-type blind spot stays a blind spot" without qualification; the ruling in + * §Function existence narrows it by exactly one axis. `type == 'grid'` and + * `type(record.x) == string` are unchanged — the first is refused as `no such + * overload: type == string`, which is not a call verdict, and the second + * type-checks clean. What is no longer blind is a call to a name the + * environment does not register at all. Everything the checker says about the + * TYPES flowing through a `dyn` predicate stays unread. + * * ### The one position this rule does not judge (#7696) * * A bare word on the RIGHT of `==` / `!=`, on a **metadata-editing form** @@ -328,10 +424,11 @@ import { collectCelRootIdentifiers, firstUndeclaredReference, + firstUnknownFunctionCall, parseCelToAst, parseCelToAstWithReason, } from '@objectstack/formula'; -import type { CelAstNode, CelBoundsOverrun } from '@objectstack/formula'; +import type { CelAstNode, CelBoundsOverrun, UnknownFunctionCall } from '@objectstack/formula'; import { collectionEntries } from './collection-entries.js'; import { walkPageComponents } from './page-walk.js'; @@ -349,6 +446,15 @@ export const VISIBILITY_PREDICATE_SYNTAX = 'visibility-predicate-syntax'; * consequence is identical, the FIX is not, and `--json` consumers key on the id. */ export const VISIBILITY_PREDICATE_OVER_BUDGET = 'visibility-predicate-over-budget'; +/** + * A predicate calling a function the CEL environment does not register — + * #13594, the scoped supersession of this file's parse-only ruling (see the + * module note's §Function existence). A separate id from + * {@link VISIBILITY_PREDICATE_SYNTAX} for the same reason `over-budget` is one: + * the source parses perfectly, so the dialect prescription cannot succeed on it + * and `--json` consumers key on the id. + */ +export const VISIBILITY_PREDICATE_UNKNOWN_FUNCTION = 'visibility-predicate-unknown-function'; export type VisibilitySeverity = 'error' | 'warning'; @@ -380,9 +486,9 @@ export interface VisibilityOptions { export interface VisibilityFinding { /** * `warning` for the ADR-0089 D3b advisory (`visibility-root-mislayered`); - * `error` for the three rules that gate — `visibility-predicate-syntax`, - * `visibility-predicate-over-budget` and `visibility-bare-identifier` (see - * module note). + * `error` for the four rules that gate — `visibility-predicate-syntax`, + * `visibility-predicate-over-budget`, `visibility-predicate-unknown-function` + * and `visibility-bare-identifier` (see module note). */ severity: VisibilitySeverity; /** Diagnostic rule id, e.g. `visibility-root-mislayered`. */ @@ -727,7 +833,8 @@ const MISLAYER_BY_LAYER: Record< /** * Inspect one element carrying a visibility predicate. Emits the mis-layered-root * finding (when the effective predicate's binding root does not match `layer`), - * the syntax finding, and the bare-identifier finding. + * the syntax / over-budget finding, the unknown-function finding (#13594), and + * the bare-identifier finding. * * There is no alias-KEY step here: `visibility-alias-deprecated` was retired * under #6318 (see the module note for the per-site measurement and for the D2 @@ -823,7 +930,55 @@ function checkElement( }); } - // (3) #6128 — a reference no binding root can resolve. Unlike (1) this one + // (3) #13594 — the predicate calls a function the CEL environment does not + // register. GATES, by the ruling quoted in the module note's §Function + // existence. Reached only when the front end ACCEPTED the source: a refusal + // has no type verdict to read, and the two arms above already own it. + // + // Independent of (4) rather than exclusive with it, and that is a change to + // the one-finding property this file used to state flatly. The exclusivity + // that mattered is intact — (2) and (4) still cannot both fire, because a + // source with no AST yields no identifiers to judge. What (3) adds is a + // verdict about a DIFFERENT token with a DIFFERENT fix: in `bogusFn(status)` + // the call name and the rootless value are two real defects, and an author who + // is told about only one comes back with the other. Suppressing either would + // be the silence #5149 is about, so both are reported and the property is + // restated per RULE PAIR instead of per predicate. + // + // The finding carries NO "did you mean" suggestion, and the hint says nothing + // about why — ruling refinement 2, 「不给 `nearestName` 建议。」 The measured + // hazard: `nearestName('can', )` answers `min`, two edits on + // a three-character name, jumping from a permission verb to a numeric + // function. An author who takes it (an LLM author above all, following the + // last sentence it was handed) writes `min(object, verb)` and is further from + // working than before it asked. The engine's own wording ships verbatim and + // nothing is guessed on top of it. + const unknownCall: UnknownFunctionCall | null = + source && !refusal ? firstUnknownFunctionCall(source) : null; + if (source && unknownCall) { + findings.push({ + severity: 'error', + rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + where, + path, + message: + `visibility predicate calls \`${unknownCall.name}\`, which the platform's CEL environment ` + + `does not register — ${unknownCall.detail} (predicate: \`${quoteSource(source)}\`). The ` + + `predicate parses, so nothing else reports it, and it faults the moment it is evaluated: on ` + + `a view/page surface the console falls OPEN and the element renders unconditionally (#5149), ` + + `and on an action surface — evaluated with \`throwOnError: true\` — it falls CLOSED and the ` + + `action disappears for EVERY user, including one who holds the grant, leaving one deduped ` + + '`console.warn` as the only signal (objectui#4421).', + hint: + `\`${unknownCall.name}\` is not a function this platform registers — a NAME fault, not a ` + + `dialect mistake, so re-spelling the predicate will not fix it. The callable names ` + + `advertised for authoring are the \`functions\` list \`introspectScope\` returns ` + + `(\`CEL_STDLIB_FUNCTIONS\`): pick one of those, or precompute the value into a formula ` + + `field on the object and test that field instead.`, + }); + } + + // (4) #6128 — a reference no binding root can resolve. Unlike (1) this one // GATES: a mis-rooted predicate is at least a statement about a namespace // someone binds somewhere, while a bare identifier resolves nowhere, on no // layer, under neither a total nor a sparse record (#4953) — so there is no @@ -887,8 +1042,10 @@ function isFieldObject(entry: unknown): entry is AnyRec { * * Returns findings (empty = clean). `visibility-root-mislayered` is advisory * (`warning`); `visibility-predicate-syntax` (#6253), - * `visibility-predicate-over-budget` (#7217) and `visibility-bare-identifier` - * (#6128) are `error` and the caller is expected to fail the build on them. + * `visibility-predicate-over-budget` (#7217), + * `visibility-predicate-unknown-function` (#13594) and + * `visibility-bare-identifier` (#6128) are `error` and the caller is expected to + * fail the build on them. * * The binding-root check is layer-directional (ADR-0089 D3). A form view that * declares `data: { provider: 'schema', schemaId }` is judged at `metadata` on @@ -897,7 +1054,9 @@ function isFieldObject(entry: unknown): entry is AnyRec { * predicate is flagged), or leave it at the `'runtime'` default for `*.view.ts` / * `*.page.ts` surfaces (so a `data.`-rooted predicate is flagged). The syntax and * bare-identifier checks are layer-agnostic — but the ROOT their hints prescribe - * is not, which is the second half of what #7815 fixes. + * is not, which is the second half of what #7815 fixes. The unknown-function + * check (#13594) is layer-agnostic in both halves: it prescribes a NAME, and the + * environment registers the same names on every layer. */ export function validateVisibilityPredicates( stack: AnyRec, From ac34084d29ff44e919de6e7cf294079b5587d9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:50:36 +0000 Subject: [PATCH 2/2] fix(lint): keep the tracker ids out of the unknown-function runtime message `check:doc-authoring` refuses an internal issue id in a string a runtime surface hands an author: 2 new (file,id) pairs above the ledger baseline (`objectui#4421`, one more `#5149`). A runtime string reaches authors and operators who cannot resolve `#NNNN`; the anchors move to the adjacent comment and the module note, where the reader who can resolve them is. Prose otherwise unchanged, both consequences still named. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- packages/lint/src/validate-visibility-predicates.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index e51664b6b5..608a3b36a7 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -961,14 +961,19 @@ function checkElement( rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, where, path, + // The prose carries no tracker id: a runtime string reaches authors and + // operators who cannot resolve one (`check:doc-authoring`). The anchors — + // #5149 for the fail-open half, objectui#4421 for the fail-closed half — + // are in the comment above and in the module note, where the reader who + // CAN resolve them is. message: `visibility predicate calls \`${unknownCall.name}\`, which the platform's CEL environment ` + `does not register — ${unknownCall.detail} (predicate: \`${quoteSource(source)}\`). The ` + `predicate parses, so nothing else reports it, and it faults the moment it is evaluated: on ` + - `a view/page surface the console falls OPEN and the element renders unconditionally (#5149), ` + - `and on an action surface — evaluated with \`throwOnError: true\` — it falls CLOSED and the ` + - `action disappears for EVERY user, including one who holds the grant, leaving one deduped ` + - '`console.warn` as the only signal (objectui#4421).', + `a view/page surface the console falls OPEN and the element renders unconditionally, exactly ` + + `like one carrying no predicate at all; on an action surface — evaluated with ` + + `\`throwOnError: true\` — it falls CLOSED and the action disappears for EVERY user, ` + + 'including one who holds the grant, leaving one deduped `console.warn` as the only signal.', hint: `\`${unknownCall.name}\` is not a function this platform registers — a NAME fault, not a ` + `dialect mistake, so re-spelling the predicate will not fix it. The callable names ` +