lint: refuse a visibility predicate calling a function the CEL environment does not register - #14359
Conversation
…ered CEL function
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…sibility-predicate-function-existence
…essage `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift Check10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f96d0d6732e32da6f8e111a799782ee142716c12 && git checkout f96d0d6732e32da6f8e111a799782ee142716c12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8094834060c0606c9c62aabf24e3bec380c9a3d6 ac34084d29ff44e919de6e7cf294079b5587d9c3 && git checkout -B drift-repro 8094834060c0606c9c62aabf24e3bec380c9a3d6 && git merge --no-ff ac34084d29ff44e919de6e7cf294079b5587d9c3
node scripts/docs-audit/affected-docs.mjs --json 8094834060c0606c9c62aabf24e3bec380c9a3d6 |
|
Provenance for the ready flip + auto-merge: contract review PASS in-seat at tier on Generated by Claude Code |
Fixes #13594
Implements the ruled branch one — director batch #21, ruling comment 5479573383, executed 5479679994. The six refinements there are binding and are not re-adjudicated here.
NOT MEASURED — read the whole change inside this bound
Carried forward per ruling point 4, declared to the maintainer before the ruling and accepted with it. The census that supplied the premise (host-registered extra CEL functions = 0, positive control firing) reached objectstack, objectui and one shipped host app. It did NOT reach:
cloud— unreachable to the census seat. NOT MEASURED.objectos— unreachable to the census seat. NOT MEASURED.cloudandobjectosare precisely the repos that carry host configuration. If a host in either registers extra CEL functions, the predicates using them are now refused atobjectstack validate. The measured 0 is a 0 about three reachable repos, not about the platform.FormulaFunctions) is a DIFFERENT engine and is not in this gate's jurisdiction — spelled out so the census's zero is not read as covering it.ExpressionEvaluatorroutes a{ dialect: 'cel' }envelope to@objectstack/formulaand passes no functions;registerFunctionthere feeds only the template branch.The premise, re-measured on this tree before any code was written
Zone 2.1 required both halves measured. Both hold.
(a)
validateExpressionalready refuses unknown calls — so the card's title premise (that the ADR-0032 validator was the hole) stays falsified, as two seats found independently:(b) the lint gate passed the same sources CLEAN — measured on one fixture stack carrying six predicates, so the two CLEAN rows sit beside two rows that FIRE and are therefore a reading, not a blank run:
What changed
@objectstack/formula— one new export,firstUnknownFunctionCall(source)(plus itsUnknownFunctionCalltype), in a newsrc/unknown-function.ts.It answers exactly one question — does this source call a name the evaluation environment does not register — and returns
nullfor every other outcome, so no consumer has to grade acheck()fault itself. The oracle is the environment (refinement 1: 「oracle = 引擎实际注册集(cel-jscheck()裁定),⛔ 不是CEL_STDLIB_FUNCTIONS常量。」), read through the samebuildEnvseamcelEngine.compileandcelEngine.evaluatebuild with.CEL_STDLIB_FUNCTIONSis untouched — #13933 owns the 35-vs-72 drift.The cel-js
found no matching overload for '…'extraction moved into this module andvalidate.tsnow imports it, so the two readers of that message cannot drift on which token cel-js named.validate.tskeeps its own separate question (is the name ADVERTISED, for wording a hint) — that behaviour is unchanged and its pins are green.@objectstack/lint— one new rule,visibility-predicate-unknown-function(severityerror) invalidate-visibility-predicates.ts, covering both call forms. The message quotes the engine's own verdict verbatim so publish time and run time read as one system, and carries no "did you mean" suggestion (refinement 2: 「不给nearestName建议。」 — nearest-name over the function namespace answersminforcan).The module note was rewritten, not edited around (refinement 4). The old parse-only ruling is quoted intact as a block quote, then narrowed: every sentence in it still holds and
visibility-predicate-syntaxstill gives only the parse verdict; what changed is that one verdict insidecompile()turned out not to be a type-check. A new section carries the evidence (objectui#4421 plus the before/after table above), this ruling's authority, and the four boundaries. The pinned CEL-type blind spot is narrowed in the test that pins it — its title now reads "…stays blind, function existence excepted" and it asserts the narrowing directly, so the change is visible where the old absolute claim was rather than silently elsewhere.Scope discipline — what still stays blind (refinement 3)
「只拒未知函数裁定,⛔ 不搬运
check()的其他抱怨。」 Each row measured and pinned:type(record.x) == stringdynCEL, type-checks — the shape the blind-spot pin protectstype == 'grid'no such overload: type == string— not a call verdict1 + 'a'no such overload: int + string— no call in itupper(1, 2)upperIS registered; arity/type fault, not existencesplit('a,b')splitIS registered, receiver-only; a call-FORM fault, not existencerecord.name.split(',')record.created.getFullYear() > 2020That last group is why the membership set is every registered name, bare-callable and receiver-only alike: narrowing it to the bare-callable 39 would turn call-form mistakes into existence claims that are false.
Refusing an unregistered call is not a false positive. Validation and runtime are the same builder —
compileandevaluateboth callbuildEnv— measured by the census at 53 probes, 0 divergent verdicts. A call this refuses would have faulted.Positive controls
unknown-function.test.tsenumerates the registration set offbuildEnv().getDefinitions()(measured, not transcribed — the same seamcel-stdlib-drift.test.tsuses, and deliberately not read back out of the module under test) and probes each name asNAME(record.x)andrecord.x.NAME(record.y). The rule never calls a registered name unknown. A companion case derives the registered-but-unadvertised gap from the two sets and fails if it ever empties, so the control cannot go vacuous; a negative control proves the same probe shape DOES refuse a genuinely absent name.type,map,filter,split,getFullYear,json,substring,indexOf,lowerAscii,exists_one,hasValue,orValue) each asserted absent fromCEL_STDLIB_FUNCTIONSfirst, so no row can pass vacuously.runtime-gate.test.tsdrivesrunRuntimeAuthoringRules— the same functionmetadata-protocol's runtime authoring gate calls — with aviewbody a Studio / REST / MCP author could save, and pins both the refusal and its accept control. A rule that only worked throughvalidateVisibilityPredicatesand never reached the door would be a declaration nobody reads.Corpus sweep (Zone 2.4)
examples/**+apps/**, every authored visibility predicate extracted (both quote styles, the{ source }envelope and the tagged-templatePform) and judged:Widened to
packages/ content/ docs/as well: 302 predicates, 4 refusals — three are this PR's own new negative fixtures, and the fourth is a doc-comment insidepackages/lint/scripts/check-doc-formula-expressions.mjsthat quotesuser.hasRole('admin')as a deliberate specimen of this exact defect class. Zero authoring defects found; nothing fixed out of scope.Verification
Head the union was run on:
ac34084d2.pnpm --filter '@objectstack/formula' exec vitest run— 27 files, 712 tests passedpnpm --filter '@objectstack/lint' exec vitest run— 93 files, 2749 tests passedpnpm --filter '@objectstack/formula' --filter '@objectstack/lint' typecheck— bothDonepnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no narrowing claimednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the last commit; the PM hint listed 19 and the derived set is a superset). 31 exit 0. Two arePREREQUISITE NOT MET(exit 3, the gates' own distinct code, captured before any pipe) and are recorded NOT MEASURED, never as a pass:check-test-completeness.mjsgrades a savedturbo run testlog CI tees and no local run produces, andscripts/pm/check-half-states.mjsneeds repo-scoped GitHub reads this container is refused (/rate_limit200 with 15000 left,/repos/…403 — the session gate, not quota). The two that were exit 3 for a missingdist/were re-run after building the workspace closure and both pass.Verdict lines, quoted from the gates rather than from an exit code:
One real RED was found and repaired rather than worked around:
check:doc-authoringrefused the tracker ids in the new runtime message string (objectui#4421new, one more#5149above a 3-pin baseline). A runtime string reaches authors and operators who cannot resolve#NNNN; the ids moved to the adjacent comment and the module note. Adding baseline entries is maintainer-only and was not taken.Ablations — both legs rebuild-free by construction, both restored byte-identically
Both suites import their subject through a relative source specifier inside the same package, so vitest resolves the source file and no rebuild is involved; each mutation was proven on disk before the run and each restore proven after.
firstUnknownFunctionCall(source)replaced bynull)e51664b6tof554e875return null)1da7806cto43e1c9abunknown-function.test.tsRestore is
git checkout HEAD -- ABSOLUTE_PATHinside anEXIT INT TERMtrap; proven bygit diff HEADempty andgit hash-objectequal to the HEAD blob for both files after each leg.@objectstack/formulathrough itsdist/(it is a registered row inKNOWN_UNALIASED_TEST_IMPORTS), so the formula ablation was deliberately run against the formula suite only. It is not evidence about the lint suite and is not offered as such.NOT MEASURED in the verification itself
Both packages'
typecheckexcludes every*.test.ts, so the green above says nothing about the three test files this PR writes or edits. Measured separately with a program that includes them (tsc --listFilesconfirms all three are in it): zero errors in this PR's test files. The pre-existing errors that program reports — 9 in fivepackages/formulatest files, 16 in fivepackages/linttest files — are the already-filed class (#12511 / #4311), untouched here.Clause-② self-reading, from the diff
yes, on both limbs the PM declared, and the PR carriesneeds:contract-reviewper ruling point 6.objectstack validateand at the runtime publish door. Population measured at zero in every reachable corpus; unbounded forcloud/objectos/ third-party apps per the hat above.@objectstack/formulagainsfirstUnknownFunctionCall+UnknownFunctionCall;@objectstack/lintgainsVISIBILITY_PREDICATE_UNKNOWN_FUNCTION. Both packages takeminorin one changeset.Declared file surface — one amendment, stated rather than done quietly
The claim declared four files. This PR touches a fifth:
packages/lint/src/index.ts, one line, exporting the new rule-id constant. Its three siblings (VISIBILITY_PREDICATE_SYNTAX,VISIBILITY_PREDICATE_OVER_BUDGET,VISIBILITY_BARE_IDENTIFIER) are exported on the adjacent lines, and--jsonconsumers and allowlists key on the id — an id reachable only as a string literal is the inconsistency, not the fix. Same defect class, mechanical, correct shape pinned by the three siblings, no new verification surface.packages/lint/src/validate-expressions.ts— the declared ⛔ — is not touched.packages/lint/src/runtime-gate.test.tsis likewise beyond the four named files and inside "both packages' tests": it is where the publish-door pin belongs, beside its siblings.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code