feat(app-shell): warn at typing time on a record-scope data.* predicate - #9185
Conversation
…cate `@objectstack/formula`'s `SCOPE_ROOTS` carries `data`, so at `scope: 'record'` the authoring lint accepts `data.status == 'x'` with zero findings — measured on `@objectstack/formula@17.4.0`. objectui#5741 retired that spelling on runtime record surfaces and objectui#8166 stopped this tier binding an ambient `data`, so the predicate now faults at runtime with `Unknown variable: data` while the author still sees a green lint. The diagnostic arrives at misbehaviour time instead of at typing time. Wire `@object-ui/core`'s already-exported `detectNonCanonicalRowSpelling` into `celAuthoring.lintCelPredicate` as a WARNING, gated on `scope === 'record'`. - WARNING, never error: every save gate on this tier counts `severity === 'error'`, so the accepted set is unchanged. Narrowing the accept set stays the producer-side half in `@objectstack/formula`. - Only the `metadata-layer-root` arm is consulted (`row = null` disables the bare-shorthand arm): at record scope the engine already errors on a bare identifier, and at flattened scope a bare identifier is the CORRECT RLS spelling — all three genuine RLS predicates in this repo fire that arm. - Loaded lazily, feature-detected and error-swallowing, mirroring the engine loader, so the CEL parser stays out of the eager bundle. Not a re-run of the warning objectui#5741 deleted: that removed the runtime warning from the hot path and kept the export "as the offline instrument". This is the editor bridge classifying authored text, not a render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
A live control must describe a world the change does not create, so it has to survive the ablation. Asserting the warning's presence inside the accept-set control made it a second true-positive pin: it went red with the wiring removed, for a reason that has nothing to do with the claim it exists to make. It now asserts only that the predicate raises no `error` — true with and without the advisory — so it reddens on exactly one change: promoting the advisory to `error`, which is the narrowing this card must not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Verification closed out at
Two coverage proofs rather than assumptions:
Narrowing, declared. Adjacent open card, deliberately not touched. objectui#8167 is already open for it: 🤖 Generated with Claude Code https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
内容 PASS + 契约审查 PASS。 ⏸️ arm 等自身 test shard 转绿。
|
| 事件 | 你的处置 |
|---|---|
--reporter=basic 被 vitest 4 在任何测试跑之前拒绝 |
NOT MEASURED,重跑,⛔ 没当成红 |
scripts/check-nul-bytes.mjs MODULE_NOT_FOUND(本仓叫 check:control-bytes) |
NOT MEASURED,换真名重跑 |
pnpm 把 --concurrency=2 转发进脚本,tsup 挂掉 |
NOT MEASURED,把旗标挪到 --filter 前重跑 |
--no-inline-config 报 16 个 error |
识别为objectstack 的拼法不是本仓的,且那些 error 是该旗标制造的 unused-disable-directive ⇒ 丢弃,不作为关于本分支的读数 |
⭐ 最后一条尤其好:一个看起来像发现的输出,被判定为仪器制造的产物。
契约审查(同形)—— PASS
--pair 9185exit 0(我自己跑的),两侧载体一致。- ⛔ 无新增已发布导出:
detectNonCanonicalRowSpelling本来就在@object-ui/core的已发布面上(rowPredicateCanon.ts:152,并由rowPredicateCanon.test.ts:167的typeof core.…钉着);本 PR 只是消费它。 - ⛔ 无 schema、⛔ 无新依赖边、⛔ accept set 未收窄(有活对照)。新增的
RowCanonModule/rowCanonAdvisory都是celAuthoring.ts的模块私有声明。 - ⇒ 摘掉两侧的
needs:contract-review。
记录
- objectui#8167 已开着、⛔ 没有重复立卡:
ConditionBuilder不传scope,五个 record 挂载点按flattenedlint。你的 advisory 门在scope === 'record'⇒ 今天够不到它们,那张卡声明 scope 的那天就自动覆盖。dedupe 控制项点亮(同次搜索也返回了detectNonCanonicalRowSpellingis exported, written and called by NOTHING on the hot path — wiring it intocelAuthoringwould give the author the diagnostic at typing time instead of at misbehaviour time #8972/finding(app-shell):data.*lints CLEAN atscope:'record'and resolves against the host's ambientdata, never the record —visibleWhen/readonlyWhen/requiredWhensilently constant-false today #8166/finding(app-shell/metadata-admin): the conditional-formatting CEL editor lints row predicates in theflattenedscope and advertises bare fields — declared-but-unbound once #5741 (Phase 2) retires the bare-field binding #7727)。 dataNamesRow名字已过时(Carrier for Phase 2 of the row-predicate deprecation: the stored-metadata survey that must size the removal window before any spelling retires #5741 之后 record 面上没有东西绑data)—— 行为正确且已钉,只有名字是旧的。⭐ 你写了「Carrier: the next PR that touches that signature. If none ever does, nobody carries it — stated rather than implied.」⛔ 没有假装它一定会被接手。
⏸️ 为什么暂不 arm
⛔ 不在四个 shard + Type Check 跑完前 arm。全绿即摘载体标签 + flip ready + SQUASH。⛔ 不需要你再做任何事。
Generated by Claude Code
Fixes #8972
Wires
@object-ui/core's already-exporteddetectNonCanonicalRowSpellingintocelAuthoring.lintCelPredicateas a WARNING, gated onscope === 'record'.Placeholders below are spelled as CAPITAL WORDS (
record.FIELD) rather thanwith angle brackets, because tag-shaped fragments are deleted from stored issue
and PR bodies — a before/after table written with them renders as "nothing
changed".
The gap this closes, measured
@objectstack/formula@17.4.0'sSCOPE_ROOTScarriesdata, so atscope: 'record'the authoring lint accepts the retired spelling with zerofindings. Measured directly against the installed engine:
validateExpression('predicate', …, {scope:'record'})record.status == 'x'{ ok: true, errors: [], warnings: [] }status == 'x'ok: false— error naming therecord.statusfixdata.status == 'x'{ ok: true, errors: [], warnings: [] }objectui#5741 retired
data.*on runtime record surfaces and objectui#8166stopped this tier binding an ambient
data, so that third row now faults atruntime with
Unknown variable: datawhile the author still sees a green lint.The diagnostic arrives at misbehaviour time instead of at typing time.
A1 — this AGREES with objectui#5741; it does not reopen it
The ruling (director seat, 2026-09-02, maintainer verbatim
5741 B 治理审计 y)says, in the same sentence that removes the runtime half:
packages/core/src/evaluator/listConditional.ts:317-322records the same splitin the tree:
What that ruling removed is a render-time warning on the runtime hot
path. This call site is neither: it is the editor bridge classifying authored
text as the author types it, which is the sweep case one document at a time.
evalRowPredicateandlistConditional.tsare untouched by this PR — the hotpath still has no detector. The rationale is left as a docblock at the wiring
point so the next reader does not re-derive it.
objectui#8166's two refusals are also respected. It refused de-advertising a
root and it refused filtering a diagnostic in
celAuthoring.ts— "there is nodiagnostic to filter, the absence of one is the defect". This adds the absent
diagnostic. It does not narrow the accept set, which that ruling assigns to
@objectstack/formula.A2 — cross-package dependency: no new edge
@object-ui/app-shell's manifest already carries"@object-ui/core": "workspace:*", and 48 non-test files underpackages/app-shell/srcimport itstatically. Nothing was added to any manifest.
The import here is nonetheless dynamic, mirroring the engine loader:
rowPredicateCanon.tsimports@objectstack/formulaat module scope, so astatic import would drag the CEL parser into whatever chunk holds this module
and undo the split the file header describes.
A3 — the detector is correct for THIS seam, and only one of its arms is
The detector had never run on a hot path, so its true/false-positive rate was
unmeasured. It was run over the tree before anything was wired.
Corpus: every
git ls-filespath, JSON parsed structurally and TS/TSX/MDscanned for the keys a
celAuthoringeditor writes (visibleWhen,readonlyWhen,requiredWhen,condition,expression,using,check,plus the runtime row-predicate keys
visible/disabled/enabled).612 distinct predicate strings, 473 of them parseable as CEL.
Instrument control (lit). The same instrument, in the same run, on the two
true positives pinned at
packages/core/src/evaluator/__tests__/rowPredicateCanon.test.ts:56and
:64:A second positive control: 250 of the 612 strings are rooted at
record, sothe sweep demonstrably reached predicates rather than reporting a clean zero
from never having parsed anything.
Seam population — the 29 predicates in the tree that sit at a record-scope
celAuthoringmount (ConditionalFormattingEditor,ObjectFieldInspectorfield rules and formula,
clientValidation.validateObjectFieldRules, thecelGatesuites):ConditionalFormattingEditor.test.tsxcondition: "data.status == 'overdue'"— the fixture whose own comment called it a KNOWN GAP.
record.*,app.name(the objectui#8155 case),record.statu(typo), bare identifiers and malformed sources all returnnullfrom the detector.The bare-shorthand arm is deliberately NOT wired, and the sweep is why.
Passing
row = nulldisables it:scope: 'record'the engine already errors on a bare identifier and namesthe
record.FIELDfix, so the arm could only duplicate an existing error;scope: 'flattened'a bare identifier is the correct RLS spelling —organization_id == current_user.organization_idis the editor's ownplaceholder. All 3 genuine RLS predicates in this repo fire that arm:
a 100% false-positive rate on that tier.
The layer trap, measured rather than assumed. 8 of the sweep's
data.*hitsare
SchemaForm.visibleWhen.test.tsx/SchemaForm.optionVisibleWhen.test.tsx— the metadata-editing layer where
dataIS canonical (ADR-0089 D3). Those areexactly the false positives this must not produce, and it cannot:
SchemaFormevaluates through
views/metadata-admin/predicate.tsand never reacheslintCelPredicate. That is why the gate isscope === 'record'and not asource pattern.
Honest limit on the corpus. This repo holds no customer-authored metadata;
the population above is in-repo fixtures and example-app documents. Sizing the
stored population needs the export objectui#5741 was blocked on, and this PR
makes no claim about it. It does not need to: the advisory is non-blocking, so
a stored
data.*predicate keeps saving exactly as it does today.A4 — source-text pin census
222 files. Zero name
rowPredicateCanon.ts,listConditional.ts,celAuthoring.ts,CelPredicateField.tsxorConditionalFormattingEditor.tsx.Control lit in the same run: the query returns
packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx, which does namepackages/plugin-kanban/src/KanbanImpl.tsxat its line 161 — so the zero is areading, not a broken query.
rowPredicateCanon.schemaCatalog.test.tsreads theexamples/schema-catalogJSON corpus, not this PR's files, and is unaffected.
WARN, not REFUSE — in falsifiable form
Every save gate on this tier counts
severity === 'error'and nothing else(
ConditionalFormattingEditor.tsx:268,PermissionAdvancedFacets.tsx:222,ObjectFieldInspector.tsx:470,ConditionBuilder.tsx:342,clientValidation.ts:815,830), andCelPredicateFieldsetsaria-invalidfromthe same count. So "zero errors" is "still accepted", and two live controls pin
it: one asserts the record-scope
data.*predicate raises no error, one assertsthe editor leaves
aria-invalidunset and applies noborder-destructive.Both redden on exactly one change — promoting the advisory to
error.A third live control pins that flattened RLS predicates, bare identifiers
included, come back with no findings at all.
Ablation
The wiring block was deleted literally, proved on disk, run, and restored from
HEADwith the restore proved by state rather than by an exit code.Exactly the two true-positive pins redden; all three live controls stay green on
the ablated leg, which is what makes them controls rather than a second copy of
the true-positive pin. (An earlier attempt mixed "the warning exists" into the
accept-set control; commit 2 splits it back out, for that reason.)
Verification
pnpm exec vitest run packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx—Test Files 2 passed (2) · Tests 64 passed (64)node scripts/check-changeset-presence.mjs—exit 0,declares 1 changeset(s)node scripts/check-changeset-no-major.mjs—exit 0,No changeset declares a major bumppnpm check:control-bytes—exit 0,scanned 7375 tracked text file(s)node scripts/check-governed-queue-guard.mjs --test …—NOT GOVERNEDpackages/app-shell/suite,type-checkand the narrowedlintreadingare added below as they land; see the report on objectui#8972 for anything
still open at report time.
Every exit code above was captured by redirecting to a file first, never read
through a pipe.
Acceptance notes
Out of scope, noted, not filed:
detectNonCanonicalRowSpelling'sdataNamesRowparameter is named for thePhase-1 world where
datawas bound to the row. Since objectui#5741 nothingbinds it on a record surface, so the name no longer describes any live
binding; what the guard actually decides — per its own rationale — is whether
datais a legitimate root on the calling surface. The behaviour is correctand pinned; only the name is vintage. Naming alone, so not filed. Carrier if
one is ever wanted: the next PR that touches that signature.
rootsoverride lists (ROW_PREDICATE_ROOTS,FIELD_RULE_ROOTS,FORMULA_ROOTS) each restate a subset of the engine's vocabulary in adifferent file. Observation only, no measured defect.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code