Skip to content

Commit e48d861

Browse files
fix(lint): 字段级 *When 的用户根拒绝覆盖 ADR-0068 的全部三种拼写 (#6585) (#6711)
#6290(PR #6584)给字段级 `visibleWhen` / `readonlyWhen` / `requiredWhen` 加了一条按面的用户根拒绝,处方也指向真实存在的面。但它只匹配 `current_user` 一个拼写,而 ADR-0068 D1 把 `user` / `ctx.user` 定为**同一个对象的别名** (`buildScope` 把同一个 `EvalUser` 引用挂在四个名字下)。于是同一个语义错误 在一种拼写下报错、在另外两种下**完全静默** —— 两者一直在 `SCOPE_ROOTS` 里, 裸引用检查也从不报它们。作者随手挑了三个 ADR 认定等价的拼写里的哪一个, 决定了他拿不拿得到构建期诊断。 失败方向就是 #6146 那个:未绑定根 ⇒ fault ⇒ 可见性 fallback 为 `true`, 本想按角色藏起来的字段对所有人恒可见,且无任何信号。 三个根现在共用一条判定、一条处方、一条文案,只有文案里点名的那个根不同。 选项级不受影响:per-option `visibleWhen` 走宿主谓词作用域,那里每种拼写都 绑定用户,所以 showcase 的角色门控选项在别名下同样合法(已钉)。 **`ctx` 按整根判,而不是只判 `ctx.user` 形态。** 在这个面上这就是事实: `buildScope` 只在求值携带用户时才创建 `ctx` 根,而字段级没有任何调用点传 用户 —— 服务端绑 `record` + `previous`(+ `parent`),客户端 `evalFieldPredicate` 绑 `record` + `previous` + 调用方 scope,而后者实测 只可能是 `{ parent }`。所以 `ctx.locale` 与 `ctx.user.id` 在这里同样 fault。 否掉窄读法的理由:它需要源码层面的拼写匹配,而拼写匹配正是本条规则要消灭 的东西 —— 那会把同一个分岔下移一层(`ctx["user"].id` 静默、`ctx.user.id` 报错),同时为了一个更窄的规则**名字**放掉一个真实的 fail-open fault。 `ctx` 在别处仍是 ActionEngine 的谓词根,不受影响:平台自己的 `ctx.user` 谓词全在 action `visible` 上(`sys-user.object.ts`、`sys-invitation.object.ts`), 本规则从不读那个面,该放行已钉为测试。 实测扫描:字段级 `*When` 读取任一用户根的用例,在 `examples/`、`packages/` 与下游 `objectui` 仓中均为**零** —— 按槽位名扫一遍、再按别名拼写本身扫 一遍(防止带逗号的谓词藏起来),两遍都是零。三个示例 app 的 `objectstack validate` 全绿,无新增发现。 反向验证(方向在运行前已预判):把根集合改回 `['current_user']`,新增用例 中恰好 9 条转红,失败形态是 `expected [] to have a length of 1 but got +0` —— 即完全静默,正是本单描述的缺陷;三条接受类钉子(选项级别名、action `ctx.user`、`record` 成员同名)在两个版本下都绿,因为它们断言的是零发现。 Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f2e63c commit e48d861

3 files changed

Lines changed: 260 additions & 7 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the field-level user-root rejection covers all three ADR-0068 spellings, not just `current_user` (#6585)
6+
7+
#6290 gave field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` a
8+
surface-level rejection when the predicate reaches for the signed-in user, with
9+
a prescription that names surfaces which actually bind one. That check matched a
10+
single spelling — `current_user` — while ADR-0068 D1 makes `user` and `ctx.user`
11+
**the same object under different names**: `buildScope` hangs one `EvalUser`
12+
reference on `current_user` / `user` / `ctx.user` / `os.user`. So the identical
13+
semantic error produced an error under one spelling and **total silence** under
14+
the other two (both have always been in `SCOPE_ROOTS`, so the bare-reference
15+
check never fired on them either). Which of three ADR-equivalent spellings the
16+
author happened to pick decided whether they got a build-time diagnostic at all.
17+
18+
The failure direction is the one #6146 named: an unbound root faults, the fault
19+
falls back, and visibility's fallback is `true` — so a predicate written to HIDE
20+
a field by role left it visible to everyone, silently.
21+
22+
All three roots now share one verdict, one prescription and one message; only
23+
the root named in the message varies. Nothing about the option level changes:
24+
per-option `visibleWhen` resolves against the host's predicate scope, which
25+
binds the user under every spelling, so the showcase's role-gated option
26+
(`'admin' in current_user.positions`) stays legal — under the aliases too.
27+
28+
**`ctx` is judged as a whole root, not only in `ctx.user` form.** At this
29+
surface that is simply what is true: `buildScope` creates the `ctx` root *only*
30+
when the evaluation carries a user, and no field-level site passes one — the
31+
server binds `record` + `previous` (+ `parent`) and the client's
32+
`evalFieldPredicate` binds `record` + `previous` + a caller scope that is only
33+
ever `{ parent }`. `ctx.locale` therefore faults exactly like `ctx.user.id`
34+
here. The narrower reading was rejected because it needs a source-level spelling
35+
match, which would re-open this very fork one level down (`ctx["user"].id`
36+
silent, `ctx.user.id` rejected) while leaving a real fail-open fault
37+
unreported. `ctx` remains ActionEngine's predicate root elsewhere and is
38+
untouched there — the platform's own `ctx.user` predicates all sit on action
39+
`visible` (`sys-user.object.ts`, `sys-invitation.object.ts`), a surface this
40+
rule never reads, and that acceptance is pinned.
41+
42+
Sweep: field-level `*When` predicates reading any user root measure **zero**
43+
across `examples/`, `packages/` and the downstream `objectui` repo, by both a
44+
slot-keyed scan and an alias-keyed one — so no shipping metadata is refused by
45+
the widening. `objectstack validate` stays clean on all three example apps.

packages/lint/src/validate-expressions.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,170 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
751751
});
752752
});
753753

754+
/**
755+
* ── The ADR-0068 aliases get the SAME field-level verdict (#6585) ────────
756+
*
757+
* D1 makes `user` and `ctx.user` aliases of `current_user` — `buildScope`
758+
* hangs one `EvalUser` reference on all three spellings — so the semantic
759+
* error above is the same error under any of them. #6584's first cut
760+
* matched only the canonical spelling: `'admin' in user.positions` and
761+
* `'admin' in ctx.user.positions` sailed through in silence (both roots
762+
* have always been in `SCOPE_ROOTS`, so the bare-ref check never fired
763+
* either), and which spelling the author picked decided whether they got a
764+
* diagnostic. These tests close that fork and pin its edges.
765+
*
766+
* `ctx` is pinned WHOLE-ROOT deliberately: at field level `buildScope`
767+
* never creates `ctx` at all (it exists only when the evaluation carries a
768+
* user, which no field-level site passes), so `ctx.locale` faults exactly
769+
* like `ctx.user.id`. The other side of that decision is pinned too —
770+
* `ctx.user` on an ACTION `visible` (ActionEngine's surface, where `ctx`
771+
* genuinely binds — the platform's own `sys_user` actions ship it) must
772+
* stay accepted.
773+
*/
774+
describe('`user` / `ctx.user` aliases at field level (#6585)', () => {
775+
const slots = ['visibleWhen', 'readonlyWhen', 'requiredWhen'] as const;
776+
777+
it.each(slots)('rejects `user` on %s — same object as `current_user`, same unbound surface', (slot) => {
778+
const issues = validateStackExpressions({
779+
objects: [{
780+
name: 'showcase_deal',
781+
fields: { amount: { type: 'number', [slot]: "'admin' in user.positions" } },
782+
}],
783+
});
784+
const hit = issues.filter((i) => i.where === `object 'showcase_deal' · field 'amount' ${slot}`);
785+
expect(hit).toHaveLength(1);
786+
expect(hit[0]!.severity).toBe('error');
787+
// The message names the spelling the author WROTE — a diagnostic that
788+
// talks about `current_user` to an author who typed `user` sends them
789+
// hunting for text that is not in their file.
790+
expect(hit[0]!.message).toMatch(/`\w+` reads `user`/);
791+
});
792+
793+
it.each(slots)('rejects `ctx.user` on %s', (slot) => {
794+
const issues = validateStackExpressions({
795+
objects: [{
796+
name: 'showcase_deal',
797+
fields: { amount: { type: 'number', [slot]: "'admin' in ctx.user.positions" } },
798+
}],
799+
});
800+
const hit = issues.filter((i) => i.where === `object 'showcase_deal' · field 'amount' ${slot}`);
801+
expect(hit).toHaveLength(1);
802+
expect(hit[0]!.severity).toBe('error');
803+
expect(hit[0]!.message).toMatch(/`\w+` reads `ctx`/);
804+
});
805+
806+
/**
807+
* The whole-root half of the `ctx` decision: a `ctx` read that never
808+
* touches `.user` is just as unbound at field level — `buildScope` only
809+
* creates the root when a user is carried, and no field-level site
810+
* carries one — so it must not slip through a `.user`-form-only match.
811+
*/
812+
it('rejects a bare-`ctx` NON-user read too — the root itself is unbound at field level', () => {
813+
const issues = validateStackExpressions({
814+
objects: [{
815+
name: 'showcase_deal',
816+
fields: { amount: { type: 'number', visibleWhen: "ctx.locale == 'en'" } },
817+
}],
818+
});
819+
const hit = issues.filter((i) => i.where.includes("field 'amount' visibleWhen"));
820+
expect(hit).toHaveLength(1);
821+
expect(hit[0]!.message).toMatch(/`visibleWhen` reads `ctx`/);
822+
});
823+
824+
it.each(["'admin' in user.positions", "'admin' in ctx.user.positions"])(
825+
'gives %s the SAME prescriptions as the canonical spelling — no per-spelling fork',
826+
(predicate) => {
827+
const [issue] = validateStackExpressions({
828+
objects: [{
829+
name: 'showcase_deal',
830+
fields: { amount: { type: 'number', visibleWhen: predicate } },
831+
}],
832+
}).filter((i) => i.where.includes('visibleWhen'));
833+
// Same three prescriptions the #6290 message test pins for
834+
// `current_user`, plus the same direction-of-failure sentence.
835+
expect(issue!.message).toMatch(/falls back to VISIBLE/);
836+
expect(issue!.message).toMatch(/option's own `visibleWhen`/);
837+
expect(issue!.message).toMatch(/readable: false/);
838+
expect(issue!.message).not.toContain('record.current_user');
839+
},
840+
);
841+
842+
/**
843+
* The whole-root widening makes root-vs-MEMBER discrimination newly
844+
* load-bearing: `record.user_id` and `record.ctx_key` name the very
845+
* strings this rule now rejects, but as MEMBERS of `record` — and
846+
* `collectCelRootIdentifiers` drops member names by design. A rule that
847+
* confused the two would reject the single most ordinary predicate an
848+
* author writes (an owner check), which is the failure mode that would
849+
* make this widening worse than the hole it closes.
850+
*/
851+
it('does NOT trip on a `record` MEMBER merely spelled like one of the roots', () => {
852+
const issues = validateStackExpressions({
853+
objects: [{
854+
name: 'showcase_deal',
855+
fields: {
856+
user_id: { type: 'text' },
857+
ctx_key: { type: 'text' },
858+
amount: {
859+
type: 'number',
860+
visibleWhen: "record.user_id != '' && record.ctx_key == 'x'",
861+
},
862+
},
863+
}],
864+
});
865+
expect(issues).toHaveLength(0);
866+
});
867+
868+
/**
869+
* The widening is FIELD-level only. Option-level `visibleWhen` resolves
870+
* against the host predicate scope, where `buildScope` mounts the SAME
871+
* user object under every ADR-0068 spelling — so an option predicate is
872+
* legal under the aliases exactly as it is under `current_user`.
873+
*/
874+
it('still ACCEPTS an option-level `visibleWhen` spelling the `user` alias', () => {
875+
const issues = validateStackExpressions({
876+
objects: [{
877+
name: 'showcase_cascading_select',
878+
fields: {
879+
tier: {
880+
type: 'select',
881+
options: [
882+
{ label: 'Standard', value: 'standard', default: true },
883+
{ label: 'Restricted', value: 'restricted', visibleWhen: "'admin' in user.positions" },
884+
],
885+
},
886+
},
887+
}],
888+
});
889+
expect(issues).toHaveLength(0);
890+
});
891+
892+
/**
893+
* The blast-radius pin the #6585 measurement was for: `ctx` IS
894+
* ActionEngine's predicate root, and the platform's own metadata ships
895+
* `ctx.user` action predicates (`sys-user.object.ts` "visible:
896+
* record.id == ctx.user.id", `sys-invitation.object.ts`). The field-level
897+
* rejection must not leak onto the action surface.
898+
*/
899+
it('still ACCEPTS `ctx.user` on an action `visible` — ActionEngine binds `ctx`', () => {
900+
const issues = validateStackExpressions({
901+
objects: [{
902+
name: 'sys_user',
903+
fields: { email: { type: 'text' } },
904+
actions: [{
905+
// The exact shape `packages/platform-objects/src/identity/
906+
// sys-user.object.ts:291` ships (`id` is a registry-injected
907+
// column, so the field-existence pass resolves it too).
908+
name: 'change_password',
909+
type: 'script',
910+
visible: 'record.id == ctx.user.id',
911+
}],
912+
}],
913+
});
914+
expect(issues).toHaveLength(0);
915+
});
916+
});
917+
754918
/**
755919
* ── The option-level traversal itself (#6290 half 3) ────────────────────
756920
*

packages/lint/src/validate-expressions.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,14 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
393393

394394
/**
395395
* A FIELD-level conditional rule (`visibleWhen` / `readonlyWhen` /
396-
* `requiredWhen`) that reaches for `current_user` — the one root the field
397-
* level does not bind (#6146, measured at both ends: `evalFieldPredicate` /
398-
* `resolveFieldRuleState` bind `record` + `previous` + `parent` and nothing
399-
* else, and objectui#1582's authoring autocomplete pins the same three).
396+
* `requiredWhen`) that reaches for the signed-in user — under its canonical
397+
* spelling `current_user` or either of its ADR-0068 D1 aliases, `user` and
398+
* `ctx.user` — the one thing the field level does not bind (#6146, measured
399+
* at both ends: `evalFieldPredicate` / `resolveFieldRuleState` bind `record`
400+
* + `previous` + `parent` and nothing else, and objectui#1582's authoring
401+
* autocomplete pins the same three). The third root is matched WHOLE (any
402+
* `ctx` read, not only `ctx.user`) — see "Why THREE roots" below for the
403+
* measurement that decides it.
400404
*
401405
* ## Why this is a rule of its own rather than a missing root
402406
*
@@ -430,18 +434,58 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
430434
* options resolve against the host's predicate scope, which binds
431435
* `current_user` (ADR-0068 / objectui#2284) — that surface is where such a
432436
* predicate belongs, which is why it is also the first prescription below.
437+
*
438+
* ## Why THREE roots, and why `ctx` is judged whole-root (#6585)
439+
*
440+
* ADR-0068 D1 makes `user` and `ctx.user` ALIASES of `current_user` — one
441+
* `EvalUser` object under every spelling (`buildScope` in
442+
* `formula/stdlib.ts` hangs the same reference on `current_user` / `user` /
443+
* `ctx.user` / `os.user`). Matching only the canonical spelling meant the
444+
* identical semantic error got a diagnostic under `current_user` and total
445+
* silence under the aliases — which spelling the author picked decided
446+
* whether they got the diagnostic, the exact fork AI authors cannot
447+
* self-check. All three roots now share one verdict and one prescription;
448+
* the message names the spelling found, nothing else varies.
449+
*
450+
* `ctx` is judged as a WHOLE root, not only in `ctx.user` form, because at
451+
* this surface that is simply what is true: `buildScope` creates the `ctx`
452+
* root ONLY when the evaluation carries a user (`scope.ctx = { user }`
453+
* inside `if (ctx.user !== undefined)`), and no field-level site passes one
454+
* — the server binds `record`+`previous`(+`parent`) (`rule-validator.ts`
455+
* `readonlyWhenBindings` / the `requiredWhen` block) and the client's
456+
* `evalFieldPredicate` binds `record`+`previous`+caller `scope` (only ever
457+
* `{ parent }`). So `ctx.locale` faults exactly like `ctx.user.id` here.
458+
* `ctx` IS ActionEngine's predicate root elsewhere — the platform's real
459+
* `ctx.user` predicates all sit on action `visible` (`sys-user.object.ts`,
460+
* `sys-invitation.object.ts`), a surface this helper never reads — and
461+
* measured usage of field-level `*When` with ANY user root is zero across
462+
* examples/, packages/ and objectui (#6585's sweep).
463+
*
464+
* The rejected alternative was matching `ctx` only in its `ctx.user` form.
465+
* `collectCelRootIdentifiers` reports ROOTS and drops member names by
466+
* design, so that reading needs a source-level spelling match — and a
467+
* spelling match is precisely the defect this rule exists to remove: it
468+
* would re-open the same fork one level down (`ctx["user"].id` silent,
469+
* `ctx.user.id` rejected), while leaving a real fail-open fault (`ctx.locale`)
470+
* unreported for the sake of a narrower rule NAME.
433471
*/
472+
const FIELD_UNBOUND_USER_ROOTS = ['current_user', 'user', 'ctx'] as const;
434473
const checkFieldRuleUserRoot = (where: string, slot: string, raw: unknown): void => {
435474
const source = celSourceOf(raw);
436475
if (!source) return;
437476
const roots = collectCelRootIdentifiers(source);
438-
if (!roots.ok || !roots.roots.includes('current_user')) return;
477+
if (!roots.ok) return;
478+
// One issue per slot even when a predicate reaches for two of them; the
479+
// tie-break is this list's order (canonical spelling first), so the message
480+
// is stable rather than dependent on AST walk order.
481+
const root = FIELD_UNBOUND_USER_ROOTS.find((r) => roots.roots.includes(r));
482+
if (root === undefined) return;
439483
issues.push({
440484
where,
441485
message:
442-
`\`${slot}\` reads \`current_user\`, but a field-level conditional rule binds only ` +
486+
`\`${slot}\` reads \`${root}\`, but a field-level conditional rule binds only ` +
443487
`\`record\` (plus \`previous\`, and \`parent\` on a master-detail line item) — ` +
444-
`\`current_user\` is unbound here, so the predicate faults and falls back to VISIBLE, ` +
488+
`\`${root}\` is unbound here, so the predicate faults and falls back to VISIBLE, ` +
445489
`leaving the field the test was meant to hide showing for everyone (#6146). ` +
446490
`To gate the CHOICES of a select by user, move the predicate to the option's own ` +
447491
`\`visibleWhen\` (\`options: [{ …, visibleWhen: … }]\`) — per-option is the one \`*When\` ` +

0 commit comments

Comments
 (0)