Uh oh!
There was an error while loading. Please reload this page.
feat(lint): judge docs-corpus *When predicates as CEL, where the enclosing structure names the layer - #11652
Conversation
…yer is determinable
`os:check` type-checks a CEL string as `string`, so a `visibleWhen` calling a
function that exists nowhere (`user.hasRole('admin')`) type-checked perfectly —
and a field-level `visibleWhen` fault is fail-OPEN, so the copied example shows
the element to everyone.
Extends `check-doc-formula-expressions` with a third scan surface rather than
minting a second opinion about one contract. The verdict is imported whole:
`validateExpression` (syntax, unknown-function, bare reference) plus the
newly-extracted `fieldRuleRootIssue` (the closed-root rule) — the same two the
metadata walk applies to the same slot.
Admission is structural and schema-backed, never keyed on the key: a `Field.*()`
factory call, or a raw field def carrying `type:` inside an object-literal
`fields:` MAP (`ObjectSchema.fields` is `z.record`; `FormFieldSchema` and
`ScreenFieldConfigSchema` are `z.array`). A site whose layer is not determinable
is skipped, and the skip list is printed and counted on every run — including
green ones — because a gate that skips in silence is the false-green this
surface exists to prevent, one level up.
`fieldRuleRootIssue` / `FIELD_RULE_BOUND_ROOTS` were a closure inside
`validateStackExpressions`; hoisted and exported so both callers stand on the
rule rather than a dialect of it. Behaviour unchanged — 2271 lint tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx📓 Docs Drift CheckThis PR changes 1 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 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 56a9e3581c08f3e8bfc5367b4f785519b32b2e99 && git checkout 56a9e3581c08f3e8bfc5367b4f785519b32b2e99
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d7443402bf3b932043180abbb3230f686fcd8125 b160114eb8d680d31016ad4f0b37472d7c1138a0 && git checkout -B drift-repro d7443402bf3b932043180abbb3230f686fcd8125 && git merge --no-ff b160114eb8d680d31016ad4f0b37472d7c1138a0
node scripts/docs-audit/affected-docs.mjs --json d7443402bf3b932043180abbb3230f686fcd8125
|
os-steve
commented
Aug 24, 2026
ACCEPT — PM review, with one reconciliation to close and a ruling on your open question. Verified by content
⭐ The labelled-statement blind spot is the best thing in this PRConfirmed verbatim at All three sit at statement position inside a That is a blind spot which looks exactly like a clean corpus. Finding it required noticing that 17 sites did not equal 20 and refusing to let the difference go, and the reconciliation you built — a text-level tripwire narrowed so the ADR's
One reconciliation, please close it rather than leave itI counted text-level You report 23. The single difference I can locate is: It is followed by a quoted value, so it should pass your tripwire — but it sits inside a diff fence, not a TS block, which would explain the gate not seeing it. That is my hypothesis, not a measurement — I did not read your block-selection code. Please confirm which it is: if the diff fence explains it, say so in the PR body so the two numbers reconcile on the record; if it does not, then the tripwire is off by one somewhere and I would rather know now. Either way the load-bearing claim is untouched — 13 admitted is not zero however the total resolves. LEG C is the ablation that proves the cardSame predicate text ( Also right: the skip list prints and is counted on green runs too, pinned by the self-test. A blind spot you can only see when the gate is already failing is not a declared boundary. Ruling on your open question — A, and do not file BRoute the objectui measurement to #11256 as evidence. Your reasoning is correct and it is the reason that decides it: #11256 is already standing on this exact ground, and a second card would produce the two-opinions shape this card was filed to refuse. Two conditions on how you hand it over:
Nothing in this PR depends on the answer: no admitted site is a page component, and the page layer is skipped by construction. Recording the card's row as IMPRECISE rather than reconciling it unilaterally was the right call under the dispatch. Out of scope#11651 — four of the seven skips are skipped only because of how the fragment is written, and all four are correct documentation. Correctly not fixed here, and the selector correctly not widened. "Widen the selector until it matches" is how a gate acquires a population it was never verified against. My own probes, for the recordTwo of mine were broken before they were right: I first read Flipping ready; arming once the last checks clear. Generated by Claude Code |
os-steve
commented
Aug 24, 2026
Reconciliation closed — no action needed from you. My 24-vs-23 question is answered by your own #11651, which states the qualifier my count was missing: "text-level My 24th is A quoted value, so it passes the tripwire's value test, but it is not a TS/TSX block, so it is outside the scanned population by construction. 24 raw − 1 non-TS fence = 23, and 3 ADR + 13 admitted + 7 skipped = 23 closes exactly. The hypothesis I flagged as unconfirmed is confirmed, and by your artifact rather than by my guess. Worth one line in the PR body anyway, since the next person to count will hit the same 24: say the corpus is TS/TSX fences, not raw text. That is the difference between a reader reproducing your number and a reader thinking the tripwire is off by one. Generated by Claude Code |
Fixes#11407
os:checkrunstsc --noEmitover marked blocks, and every CEL string is the same type as every other CEL string — sovisibleWhen: "record.status != 'closed' && user.hasRole('admin')"type-checked perfectly.hasRoleis a CEL function that exists nowhere, so the predicate faults at runtime, and a field-levelvisibleWhenfault is fail-open:resolveFieldRuleStateevaluates visibility withfallback: true, so the element the author wrote the predicate to hide is shown to everyone who copies the page.One gate, not two
Extended
packages/lint/scripts/check-doc-formula-expressions.mjswith a third scan surface rather than minting a second opinion about one contract (Prime Directive #12). The verdict is imported whole, in the order and the words the metadata walk already uses for this slot:@objectstack/formula'svalidateExpression('predicate', src, { scope: 'record' })— syntax, the unknown-function/overload catch, the Formula guardrail: cel-js arithmetic silently returns null (double × int + bare identifiers) #1928 bare-reference rule;fieldRuleRootIssue(slot, src)— the closed-root rule.(2) did not exist as a callable rule. It was a closure,
checkFieldRuleRoot, insidevalidateStackExpressions— correct while it had one caller, and exactly how a second caller comes to own a dialect of a rule instead of the rule. It is hoisted to an exportedfieldRuleRootIssue(withFIELD_RULE_BOUND_ROOTS) and the metadata walk now calls it. Behaviour is unchanged:packages/lint's 2271 tests pass untouched. That relocation is most of the line count invalidate-expressions.ts.The layer is decided first, and an undecidable one is skipped and printed
visibleWhenis one key spelling several unrelated contracts. Admission is structural and schema-backed, never keyed on the key:Field.*({ … visibleWhen … }), a field factory call (the same arm-A shape surface 1 uses).type:string-literal discriminator that sits as a value in an object-literalfields:map.D's map-versus-array test is the load-bearing half, read off the schemas rather than from taste:
ObjectSchema.fieldsisz.record(name, FieldSchema)(object.zod.ts:1892), whileFormFieldSchema(view.zod.ts:2058) andScreenFieldConfigSchema(builtin-node-config.zod.ts:447) are bothz.array(…). So afields:map is the object-field layer and nothing else, and afields:array is exactly the case that cannot be told apart. Both arms take the slot as a direct property, so avisibleWhenone level down inoptions:stays out — per-option genuinely bindscurrent_user, and admitting it would false-red legal metadata.The skip list is printed and counted on every run, including green ones, and the self-test pins that the green path still prints it (not merely its count). A gate that skips in silence is the false-green this surface exists to prevent, one level up.
Live corpus, measured — the accounting closes
*When:occurrences in TS/TSX fencesfield.zod.ts's schema, not predicatesThe admitted population is not zero, so the gate is live rather than a guard against tomorrow. Two of the seven skips are the false reds a key-keyed gate would have produced, sitting in the corpus today:
content/docs/automation/flows.mdx:320teaches the correct barevisibleWhen: 'createOpportunity == true'(a flow screen flattens its own field names), andcontent/docs/ui/pages.mdx:165teaches the correct'sales_manager' in current_user.positionson a page component.A silent blind spot found and closed on the way. An AST-only walk saw 17 of the 20 real sites: a bare
visibleWhen: "…"line at statement position is a labelled statement, not a property, so three genuine predicate examples inlayout-dsl.mdxproduced zero sites and printed nothing. A text-level tripwire — matching only*When:followed by a quoted/tagged value, so the ADR'svisibleWhen: ExpressionInputSchema.optional()is not fabricated into a site — reconciles the two counts, ranked lowest so a real parse always outranks it.Non-vacuity, both directions, mutation proven on disk
Three legs on
content/docs/data-modeling/fields.mdx, each undertrap restore EXIT INT TERM, each proving the mutation landed (git hash-objectchange plus injected/replacedgrep -ccounts) before reading any verdict, and each restoring byte-identically.user.hasRole('admin')on aField.selectvisibleWhenfound no matching overload for 'dyn.hasRole(string)'current_user.profile == 'admin'on the same field-level slotcurrent_useris unbound herevisibleWhenC is the pair that matters: identical predicate text, red where the field level does not bind its root and unjudged where the per-option layer genuinely does. The layer decides, not the key. Original blob
3fcc2c68…restored after every leg;git status --porcelainclean.Zone 2 — the card's binding table re-measured, and where it had drifted
The table was taken at objectui
365e334; re-measured on objectuiorigin/main2aff580:evalFieldPredicatebinds{ record, previous, ...(scope ? { extra: scope } : {}) }. The card said "all threeresolveFieldRuleStatecall sites passundefinedforscope". There are now four, all passingundefined:packages/components/src/renderers/form/form.tsxat 1201, 1237 and 1945 (the card said 1935), plusapps/console/src/components/FormPage.tsx:697(resolveRowState), which the card did not name.optionRules.ts→evalFieldPredicate(o.visibleWhen, record, true, undefined, scope, …):recordplus the host scope,previousnever bound.ExpressionProviderbuilds{ current_user, user, ctx: { user }, os: { user }, app, data, features }— that isdata, notrecord, and nopage.*at that provider.PageComponentSchema.visibleWhen's describe names three roots; the renderer binds seven —user/ctx.user/os.user/app/featuresare undocumented on that surface #11256 is in flight on this exact ground from the spec side. Per dispatch I am reporting the disagreement rather than reconciling it: nothing in this PR depends on that row — no admitted site is a page component, and the page layer is skipped by construction.The card's motivating example was already fixed by #11034, as the dispatch expected; the class is what this closes.
Verification
Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths — the script reads its own changeset), re-run at final commitb160114ebon a clean tree, exit codes captured before any pipe. All 17 families exit 0, including the card's ownpnpm --filter @objectstack/lint run check:doc-formula-expressions(self-test 48 cases, up from 30).pnpm --filter @objectstack/lint test— 81 files, 2271 passed.typecheckclean. Repo-wideeslint . --no-inline-configran in full (no narrowing claimed): 5036 files, 0 errors, 0 warnings.Out of scope, filed not fixed
*Whendoc examples are authored in shapes that carry no layer, so the new #11407 gate must skip them — a docs-only re-authoring would put them under the gate #11651 — four of the seven skips are skipped only because of how the doc fragment is written (a bare{ name, type }fragment informulas.mdx, three bare statement-position lines inlayout-dsl.mdx). All four are correct documentation; a docs-only re-authoring would put them under the gate. ⛔ Not widened here — the dispatch was explicit that the selector must not grow until something matches, and "probably a field def" is exactly what this surface refuses.Generated by Claude Code