Uh oh!
There was an error while loading. Please reload this page.
fix(formula): prescribe the bare call shape for a stdlib function written as a method - #14447
Conversation
…tten 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 6 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 71dc706aa2abe51edb33a8e31571acf5305746df && git checkout 71dc706aa2abe51edb33a8e31571acf5305746df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d16df741808edbe39e0b6e09c993e440bad78444 8909ee86eaa3505991b9993d3f378ff55bafe476 && git checkout -B drift-repro d16df741808edbe39e0b6e09c993e440bad78444 && git merge --no-ff 8909ee86eaa3505991b9993d3f378ff55bafe476
node scripts/docs-audit/affected-docs.mjs --json d16df741808edbe39e0b6e09c993e440bad78444
|
os-musk
commented
Sep 2, 2026
Landing provenance — engine execution seat (session ACCEPT on the card: comment 5505968164 (#14203). Flip pre-checks on head Action: Generated by Claude Code |
Fixes#14203
The defect
validateExpressionrefusedrecord.name.upper()correctly and then handed the author the generic dialect trailer — "predicates are bare CEL (e.g.record.rating >= 4)" — advice that cannot succeed, because the source already IS bare CEL and parses fine. The third instance of the family repaired for theboundsclass (#7073) and the unknown-name class (#13821), and the one neither could cover: #13821's arm fires only when the name is ABSENT fromCEL_STDLIB_FUNCTIONS, andupperis present, so this class had no prescription at all. The name is right; the call SHAPE is wrong.What changed
The
typeclass now carries two disjoint arms. The new one prescribes the bare call, assembled from the SOURCE:The spelling has to come from the source: cel-js's message names the receiver's TYPE (
dyn.upper(),string.upper()), never the author's expression, soNO_OVERLOAD_REdiscarding the receiver prefix loses nothing that could have been printed. When the receiver is not a plain dotted identifier chain —record.tags[0].upper(),(record.a + record.b).upper(),'literal'.upper(), and the measured near-missrecord.x[0].name.upper()— the message names the call SHAPE (upper(…)with the receiver as its first argument) rather than inventing a spelling it cannot derive. Inventing one would repeat, one level up, the defect this card is about.The two keys, and why one of them is not enough
Per the triage fence, the arm is keyed on membership of the bare-callable catalog, never on call shape alone — plus a second key the measurement forced.
Measured on this branch through
buildEnv().getDefinitions(), the same seamcelEngine.compileandcelEngine.evaluatebuild with (cel-js registers 72 distinct names over 134 definitions):upper,lower,isBlank,daysFromNowcontains,endsWith,matches,size,startsWith,string,trimsplit,map,getFullYearThe both-forms row is the one catalog membership alone would have got wrong:
record.name.contains()is a real receiver call faulting on ARITY, and "writecontains(record.name)" would fault just as hard. That class keeps the existing trailer, because its fault is the arguments — the blind spot #13594 deliberately keeps blind. The arm therefore also asksisReceiverRegistered(name), read from the environment rather than from a hand-copied list, so it cannot drift.Files
packages/formula/src/validate.ts—receiverCallHintandreceiverChainInSource, and thekind === 'type'routing. The two arms are disjoint by construction rather than by ordering: the new one fires only when the name IS advertised, validateExpression's unknown-function refusal hands the author the dialect prescription - advice that cannot succeed on a source that is already bare CEL #13821's only when it is not.packages/formula/src/unknown-function.ts— the declared extractor growth:receiverCallNameFromNoOverload(a sibling regex with the receiver prefix REQUIRED, so the message-shape knowledge keeps one home) andisReceiverRegistered.callNameFromNoOverloadandfirstUnknownFunctionCallare unchanged in behaviour; the memo now carries both name sets off onegetDefinitions()read.packages/formula/src/validate.test.ts— 26 cases in a new#14203block.packages/formula/src/unknown-function.test.ts— 26 cases pinning the two new exports..changeset/formula-receiver-call-prescription.md—@objectstack/formulapatch.Negatives — one per class the shape alone would have swallowed
record.name.split(','),record.dates.map(d, d),record.created.getFullYear();record.name.contains('x'),record.name.trim()) and keep the dialect trailer when they fault on arity (record.name.contains(),record.name.startsWith(),record.n.matches());upper(1, 2)) and an operator mismatch (1 + 'a') keep the trailer;record.name.nosuchmethod(),split(record.name, ','),nosuchfn(record.name)), and theboundsprescription is untouched;upper(record.name)— the prescription this arm hands out — type-checks. A repair that prescribed something unusable would be another sentence that cannot succeed.The both-forms set is pinned as a measured list, not remembered: if cel-js moves a name across that line, the pin reddens before the arm silently changes class.
Ablation
Predicted before the run: reverting the routing to
unknownFunctionHintalone sends the class back throughbracesHint(null) to the dialect trailer, so the ten prescription cases go RED and every negative stays GREEN.Measured, exactly that: 10 failed, 150 passed over the two test files. The mutation was confirmed on disk before the run — the removed text went 1 occurrence to 0 while the injected text stayed at 1, with
git diff HEAD --statshowing 1 insertion / 1 deletion — and the restore leg was proven bygit diff HEADbeing empty ANDgit hash-objecton the file equalling its HEAD blobe2b884a8(a non-empty hash, compared rather than assumed). The restoringtrapused absolute paths resolved fromgit rev-parse --show-toplevel.No rebuild leg applies.
validate.test.tsimports./validaterelatively, so vitest resolves the package's own source; nothing in this ablation readsdist/. A first attempt of the same ablation is recorded as VOID rather than as a reading: it passed the test paths package-prefixed, vitest matched no files and exited 1 — an exit 1 that is not a red gate.Verification
All of the following ran on this branch at
8909ee86e, exit codes captured after redirect, never through a pipe.pnpm --filter @objectstack/formula test— 27 files, 764 passedpnpm --filter @objectstack/formula typecheck— cleanpnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no narrowing claimednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no hand-built path list) — 32 green, pluspnpm check:error-status-conformanceandpnpm check:nul-bytesgreenpnpm check:type-check-debt --re-measuregreen: 27 ledger entries re-measured, "none above its recorded number, surplus: none". This is the reading that covers the two edited TEST files —packages/formula/tsconfig.jsonexcludes**/*.test.ts, so the package's owntypecheckscript says nothing about them (confirmed withtsc --listFiles: 0 hits). The TEST_DEBT ratchet is where they are measured, and it did not move.scripts/check-test-completeness.mjs("There is no local log to hand it, so the local reading for this gate is NOT MEASURED. It is not a red, and there is nothing here to fix.") andscripts/pm/check-half-states.mjs("the trigger-file index gathered nothing, so this result says NOTHING about whether the board carries half-states").Consumers
validateExpressionis ADR-0032's shared validator (~10 expression slots including@objectstack/lint's gate and thevalidate_expressionMCP tool). Greppedorigin/mainfor a test outsidepackages/formulapinning the dialect trailer for a receiver-call input: none — the other "are bare CEL" strings belong topackages/lintandservice-automation's own messages, different producers. Spot-checked the largest consumer anyway:@objectstack/lint's three expression validators, 422 passed.Scope
No change to
CEL_STDLIB_FUNCTIONSmembership, tostdlib.ts, or to cel-js's environment, and no change to whatvalidateExpressionACCEPTS: the receiver call was refused before and is refused after. Only the sentence the author is told to act on changes. Card labels and assignee untouched.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code