Skip to content

Commit 48a307a

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(cli): validate action visible/disabled predicates at build time (ADR-0032) (#2185)
Extends the build-time expression check to UI action `visible`/`disabled` predicates (stack-level + object-attached), record-scoped like validation rules. A record-header/row action's `visible` is evaluated by ActionEngine against `{ record, recordId, objectName, user, … }` with fail-closed semantics, so a bare field ref (`!done` instead of `!record.done`) throws at runtime and the action is silently hidden on every record — the trap behind the #2183 "Mark Done never hides" hunt. `os build` now flags it as an error with the corrective `record.<field>` message instead of shipping a dead control. @objectstack/formula: add `ctx` and `features` to the record-scope namespace roots so ambient globals real action predicates use (`record.id == ctx.user.id`, `features.multiOrgEnabled`) aren't false-positives. Verified against the full monorepo build (76 tasks) — every example + platform bundle still compiles clean; formula (201) + cli (496, incl. 5 new) tests pass. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e411a82 commit 48a307a

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/formula": patch
4+
---
5+
6+
build: validate UI action `visible` / `disabled` predicates at compile time
7+
8+
Extends the ADR-0032 build-time expression check to cover action `visible` and
9+
`disabled` predicates (stack-level and object-attached), evaluated record-scoped
10+
like validation rules. A record-header / row action's `visible` is evaluated by
11+
`ActionEngine` against `{ record, recordId, objectName, user, … }` with
12+
fail-closed semantics, so a **bare** field reference (`!done` instead of
13+
`!record.done`) throws at runtime and the action is **silently hidden on every
14+
record** — the trap behind the #2183 "Mark Done never hides" debugging hunt.
15+
`os build` now reports it as an error with the corrective `record.<field>`
16+
message instead of letting it ship.
17+
18+
`@objectstack/formula`: `ctx` and `features` are added to the record-scope
19+
namespace roots (alongside the existing `user`, `data`, `context`, …) so the
20+
ambient globals real action predicates use (`record.id == ctx.user.id`,
21+
`features.multiOrgEnabled`) are not false-positives. Verified against the full
22+
monorepo build (every example + platform bundle still compiles clean).

packages/cli/src/utils/validate-expressions.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,58 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
233233
expect(issues[0].severity).toBe('error');
234234
});
235235
});
236+
237+
describe('action visible/disabled predicates (record-scoped) — #2183 class', () => {
238+
it('flags a bare-field `visible` on a stack action (the trap that hid Mark Done)', () => {
239+
const issues = validateStackExpressions({
240+
objects: [{ name: 'showcase_task', fields: { done: { type: 'boolean' }, status: { type: 'select' } } }],
241+
actions: [{ name: 'mark_done', objectName: 'showcase_task', type: 'script', locations: ['record_header'], visible: '!done' }],
242+
});
243+
const v = issues.filter(i => i.where.includes("action 'mark_done' visible"));
244+
expect(v).toHaveLength(1);
245+
expect(v[0].severity).toBe('error');
246+
expect(v[0].message).toMatch(/bare reference `done`/);
247+
});
248+
249+
it('accepts the record-qualified form', () => {
250+
const issues = validateStackExpressions({
251+
objects: [{ name: 'showcase_task', fields: { done: { type: 'boolean' } } }],
252+
actions: [{ name: 'mark_done', objectName: 'showcase_task', type: 'script', visible: '!record.done' }],
253+
});
254+
expect(issues).toHaveLength(0);
255+
});
256+
257+
it('accepts ambient globals (ctx / features / user) used by platform actions', () => {
258+
const issues = validateStackExpressions({
259+
objects: [{ name: 'sys_user', fields: { id: { type: 'text' }, email_verified: { type: 'boolean' } } }],
260+
actions: [{ name: 'verify_email', objectName: 'sys_user', visible: 'record.id == ctx.user.id && record.email_verified == false && features.x != true' }],
261+
});
262+
expect(issues).toHaveLength(0);
263+
});
264+
265+
it('flags a bare-field `disabled` predicate but ignores a boolean `disabled`', () => {
266+
const bad = validateStackExpressions({
267+
objects: [{ name: 'crm_lead', fields: { status: { type: 'select' } } }],
268+
actions: [{ name: 'park', objectName: 'crm_lead', disabled: 'status == "converted"' }],
269+
});
270+
expect(bad.filter(i => i.where.includes("action 'park' disabled"))).toHaveLength(1);
271+
272+
const ok = validateStackExpressions({
273+
objects: [{ name: 'crm_lead', fields: { status: { type: 'select' } } }],
274+
actions: [{ name: 'park', objectName: 'crm_lead', disabled: true }],
275+
});
276+
expect(ok).toHaveLength(0);
277+
});
278+
279+
it('validates an action attached to an object (record scope = parent object)', () => {
280+
const issues = validateStackExpressions({
281+
objects: [{
282+
name: 'showcase_task',
283+
fields: { done: { type: 'boolean' } },
284+
actions: [{ name: 'mark_done', type: 'script', visible: '!done' }],
285+
}],
286+
});
287+
expect(issues.filter(i => i.where.includes("action 'mark_done' visible"))).toHaveLength(1);
288+
});
289+
});
236290
});

packages/cli/src/utils/validate-expressions.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
* agent `validate_expression` tool exactly.
1111
*
1212
* Scope (v1): flow predicates (start/decision `config.condition` + edge
13-
* `condition`) and object validation-rule / formula predicates. Each error is
14-
* located (flow/object + node/edge/field) with a corrective message.
13+
* `condition`), object validation-rule / formula predicates, and UI action
14+
* `visible` / `disabled` predicates. Each error is located (flow/object/action
15+
* + node/edge/field) with a corrective message.
1516
*/
1617

1718
import { validateExpression } from '@objectstack/formula';
@@ -160,5 +161,37 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
160161
}
161162
}
162163

164+
// ── Action `visible` / `disabled` predicates ───────────────────────
165+
// Record-scoped, same as validation rules: a record-header / row action's
166+
// `visible` is evaluated by ActionEngine against `{ record, recordId,
167+
// objectName, user, … }` with fail-closed semantics, so a BARE field ref
168+
// (`done` instead of `record.done`) throws and the action is silently hidden
169+
// on every record (the trap behind the #2183 "Mark Done never hides" hunt).
170+
// Flagging it here turns that into a build error with a corrective message.
171+
// `disabled` may be a boolean (skip) or a predicate (check).
172+
const seenActions = new Set<string>();
173+
const checkAction = (where: string, action: AnyRec, objectName?: string): void => {
174+
const obj = objectName
175+
?? (typeof action.objectName === 'string' ? action.objectName : undefined)
176+
?? (typeof action.object === 'string' ? action.object : undefined);
177+
const name = typeof action.name === 'string' ? action.name : '?';
178+
const key = `${obj ?? ''}:${name}`;
179+
if (seenActions.has(key)) return; // de-dup (actions are merged onto objects AND kept top-level)
180+
seenActions.add(key);
181+
check(`${where} · action '${name}' visible`, action.visible, obj, 'record');
182+
if (typeof action.disabled !== 'boolean') {
183+
check(`${where} · action '${name}' disabled`, action.disabled, obj, 'record');
184+
}
185+
};
186+
for (const action of asArray(stack.actions)) {
187+
checkAction('stack', action);
188+
}
189+
for (const obj of objects) {
190+
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
191+
for (const action of asArray(obj.actions)) {
192+
checkAction(`object '${objectName}'`, action, objectName);
193+
}
194+
}
195+
163196
return issues;
164197
}

packages/formula/src/cel-engine.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ const SCOPE_ROOTS = [
5353
'record', 'previous', 'input', 'output', 'os', 'vars', 'variables',
5454
'automation', 'context', 'args', 'item', 'env', 'user', 'step', 'result',
5555
'trigger', 'event', 'payload', 'data', 'params', 'config', 'settings',
56+
// UI action / predicate context (ActionEngine, renderers): the current
57+
// record plus ambient globals exposed to `visible`/`disabled` predicates.
58+
'ctx', 'features',
5659
] as const;
5760

5861
/**

0 commit comments

Comments
 (0)