Skip to content

Commit ae7f49e

Browse files
fix(lint)!: a field group's visibleWhen is judged like a field's own, at build and at the object save door (#26928)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7ejPZvNBsnV2TSPeJipsMj)_ Fixes #26646 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/**` never teaches `fieldGroups[].visibleWhen` (the two skills that mention `fieldGroups` teach key, label and membership only); the docs page `content/docs/ui/field-grouping-and-order.mdx` gains the slot and its check here ## 维护者速读 - **改了什么**:对象的字段分组 `fieldGroups[].visibleWhen` 现在和字段自己的 `visibleWhen` 用同一套表达式检查,`os validate` / `os build` 和对象保存口都会拦,错误规则 `expression-invalid`,定位到分组的 key。 - **为什么改**:分组条件写错一个字段名(如 `record.no_such_field == 'x'`)以前照样 200 保存,渲染器按失败即隐藏处理,整个分组(连标题)在所有表单和详情页消失,没有任何提示;同样的写法放在字段或动作上早就被 422 拒绝。 - **风险与回滚**:收窄(BREAKING minor):以前能存的错误分组条件现在被拒。仓库内 8 个字段分组(示例、模板、脚手架)都没有写 `visibleWhen`,零命中。回滚即撤销本 PR。 - **先看**:`packages/lint/src/validate-expressions.ts`(对象遍历末尾新增的分组循环,以及 `fieldRuleRootIssue` 里为分组改写的措辞);`packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts`;`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` 最后一个 describe 块。 - **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.object-field-group-visibility-writes.test.ts`,预期 14 个用例全绿,其中卡片原样探针被拒、定位 `object 'qa_fg_probe' · field group 'g1' visibleWhen`。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/lint` — the field-group pass.** `runStackExpressionPasses` gains one loop at the end of the object walk, after the field walk: every `fieldGroups[].visibleWhen` is judged with the field `visibleWhen` slot's rules — the `record`-scoped `check` (it parses, reads `record.FIELD` and never a bare field, names a field the object declares, calls only registered functions) and the root verdict over `record` / `previous` / `parent`. Same rule id (`expression-invalid`), located at `object 'NAME' · field group 'KEY' visibleWhen`. Not fenced on an object write, so the object save door (`PUT /api/v1/meta/object/:name`, draft promotion, package draft publish) gives the build's finding, and `os validate` / `os build` give it too. - **The root verdict is worded for the group.** `fieldRuleRootIssue` takes a `field group visibleWhen` slot: the message opens with "`visibleWhen` reads `current_user`, but a field group's section predicate binds only `record`…", and its consequence cell names the group's fail-closed gate (the renderer's `sectionGateVisible` resolves a fault to hidden, where a field's own `visibleWhen` falls back to visible). The field slots' wording is unchanged. - **Registry note** in `authoring-rules.ts` beside the #22032 pass history, with the corpus measurement. - **Docs**: one paragraph in `content/docs/ui/field-grouping-and-order.mdx` on the group's `visibleWhen`, its fail-closed gate and the check. - **Changeset**: `.changeset/26646-field-group-visible-when-judged.md`, `@objectstack/lint` minor, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`. ## Why The form renderer gates the whole group on this predicate and fails it closed, so a misspelt field hid the section on every form and record page with nothing said at authoring time, while the same text on `fields.*.visibleWhen` or `actions[].visible` was refused 422 at the same door. No pass of the expression rule read the slot (`validate-expressions.ts` had no `fieldGroups` site). ## Tests - New `packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts` (14 cases): the card's probe body and four more refused bodies (bare reference, unregistered function, syntax error, `current_user` root) each refused once at the group's key; three valid predicates and an absent one publish clean at the door and the build; door findings equal build findings; the card body earns the same message on the group as on the field's own `visibleWhen`; a stored sibling's broken group does not block this write. - New block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` (4 cases) through the real `saveMetaItem` / `publishMetaItem`: the card's probe is refused `422 INVALID_METADATA` on an active save and on a draft's promotion with the finding at the group's key and nothing landing; `record.stage == 'won'` saves; door issues equal build findings. - `validate-expressions.test.ts` meta-test: `obj` reads `fieldGroups`, and the new `fieldGroup` receiver (`key`, `visibleWhen`) is checked against `ObjectFieldGroupSchema`. - `runtime-gate.object-formula-writes.test.ts`: the "every object-borne site" pin carries a faulting group `visibleWhen`, at the build's position (after the option site, before the actions). ## Acceptance notes - **Re-measure on main `031d97a0`** (fresh showcase boot, seed admin): the card's step 1 answered **200** `state=active` and read the predicate back verbatim; step 2 (`actions[].visible`) and step 3 (`fields.stage.visibleWhen`) answered **422** `expression-invalid`. Premise holds. - **Corpus**: the repository ships 8 field groups on 4 objects (app-showcase `contact` 4 and `semantic_zoo` 2, the `create-objectstack` blank template 1, the `os init` scaffold 1; platform objects declare none) and none carries a `visibleWhen`. Nothing to fix. - **`current_user` on a group** is refused, as on a field's own `visibleWhen`. In a form whose host binds the user it used to hide the group by role in the renderer only; elsewhere it faulted and hid the group for everyone. The prescription points at field-level security for role-based hiding. ## Verification All at head `75bff196c` (main merged) unless noted. <details><summary>Live replay on a showcase boot (head 6df7deb, lint dist rebuilt)</summary> - Card body: **422** `INVALID_METADATA`, one issue, rule `expression-invalid`, path `object 'qa_fg_probe' · field group 'g1' visibleWhen`, message "unknown field `no_such_field` on `qa_fg_probe`". `GET /meta/object/qa_fg_probe` afterwards: 404. - Control `record.stage == 'won'` on the same group: **200** `state=active`. - Steps 2 and 3 unchanged: 422 at `action 'qa_go' visible` and at `field 'stage' visibleWhen`. - `os validate` (app-showcase) with `visibleWhen: "record.no_such_field == 'x'"` planted on the `work` group: exit 1, `object 'showcase_contact' · field group 'work' visibleWhen: unknown field`; restored, exit 0 with 0 field-group findings. app-crm and app-todo `validate`: exit 0. </details> <details><summary>Suites</summary> - `pnpm --filter @objectstack/lint test`: 170 files, 7485 tests passed. `typecheck`: exit 0. - `@objectstack/metadata-protocol` `protocol.runtime-authoring-gate.test.ts`: 218/218. `typecheck`: exit 0. - Full dogfood (`pnpm --filter @objectstack/dogfood test`, closure built): 334 files passed, 3 skipped; 2716 tests passed, 20 skipped; lock VERDICT command-exit 0. </details> <details><summary>Ablation (protocol block reads lint through dist)</summary> Mutated the loop to read `obj.ABLATED_fieldGroups_26646` with `scripts/ablation-replace.mjs --hold`, rebuilt lint, `ablation-dist-preflight` found the marker in 4 built files: the #26646 protocol block went **3 failed / 1 passed** (the two refusals and the parity case red, the valid control green). Restored (blob == HEAD, `git diff HEAD` empty), rebuilt, preflight `--absent` clean: 4/4 green. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 88 families; all 88 ran with exit 0. `--ran` reconciliation: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. CI runs the rest of the farm. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns the repo-wide lint run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzj32KwHUJobhFR5iHF --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5aad4e0 commit ae7f49e

8 files changed

Lines changed: 397 additions & 12 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a field group's `visibleWhen` is judged like a field's own `visibleWhen` — in `os validate` / `os build` and at the object save door, refused as `expression-invalid` at the group's key
6+
7+
`fieldGroups[].visibleWhen` gates the whole group, header included, and the form renderer fails it closed: a predicate that cannot evaluate hides the group on every form and record page. No pass of the expression rule read the slot, so a group predicate naming a field the object does not declare (`record.no_such_field == 'x'`) saved with a 200 and the section vanished, while the same text on a field's `visibleWhen` or an action's `visible` was refused 422 at the same door. The object walk now judges every field group's `visibleWhen` with the field `visibleWhen` slot's rules: it must parse, read fields as `record.FIELD` (never bare), name fields the object declares, call only registered functions, and read no root beyond `record`, `previous` and `parent`. The finding is located at `object 'NAME' · field group 'KEY' visibleWhen`, and the root refusal is worded for the group's fail-closed gate.
8+
9+
Clause-②: no (narrowing) — the object save door, `os validate` and `os build` refuse a field group `visibleWhen` they accepted before. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (an object's `fieldGroups[].visibleWhen` may no longer carry a predicate the field `visibleWhen` slot refuses)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: ObjectFieldGroupSchema.visibleWhen keeps its shape and meaning, and the build and the door now give it the verdict a field visibleWhen already gets. A refused predicate never evaluated (the group was hidden on every form), so there is no rewrite that keeps what the author meant; the author fixes the predicate, and the refusal names the field or root. -->
14+
15+
**BREAKING** accept-set narrowing at the object save door (`PUT /api/v1/meta/object/:name`, draft promotion, package draft publish) and in `os validate` / `os build`.
16+
17+
- **Before.** A field group `visibleWhen` with an unknown field, a bare field reference, an unregistered function or a syntax error saved, and the group was hidden on every form and detail page with nothing said. A `current_user` read saved too, and hid the group everywhere except in a form whose host binds the user.
18+
- **Now.** Each answers `422 INVALID_METADATA` at the door (and an `expression-invalid` error at build), located at the group's key.
19+
- **Unchanged.** A group predicate over the object's declared fields (`record.stage == 'won'`, `previous.stage != record.stage`) saves as before, and a group with no `visibleWhen` is untouched. No example, template or platform object in this repository declares a group `visibleWhen`.
20+
21+
**The remedy.** Fix the predicate the refusal names: write `record.FIELD` against a field the object declares. To hide fields by role, declare field-level security on a permission set; to gate on record state, write the predicate against `record`.

‎content/docs/ui/field-grouping-and-order.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const Contact = ObjectSchema.create({
5252
});
5353
```
5454

55+
A group can also hide itself: `visibleWhen` on a `fieldGroups` entry is a CEL predicate over `record` (plus `previous`, and `parent` on a master-detail line item), and it gates the **whole** section, header included. The gate is **fail-closed**: when the predicate is false, or cannot evaluate at all, the section disappears. `os validate`, `os build` and the metadata save door check it with the same rules as a field's own `visibleWhen`, so a predicate that names a field the object does not have (`record.stauts == 'won'`) is refused as **`expression-invalid`** at the group's key instead of silently hiding the section on every form.
56+
5557
**2. Layout grouping — form `sections` (on a view).** A specific form's explicit arrangement: which fields, which section, how many columns, collapsible. It can **inherit** the object's declared groups as its default or **override** them per form.
5658

5759
```ts

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,20 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
733733
// leaves are judged: the build and the door accept all 8, and refuse a
734734
// misspelt leaf.
735735
//
736+
// [#26646] The field-group pass joins the build and the object door: every
737+
// `fieldGroups[].visibleWhen`, the section predicate the form renderer
738+
// gates the whole group on, fail-closed. It takes the field `visibleWhen`
739+
// slot's verdict (the `record`-scoped check and the root verdict), located
740+
// at the group's key. Before it no pass read the slot, so
741+
// `record.no_such_field == 'x'` saved with a 200 while the same text on a
742+
// field's `visibleWhen` or an action's `visible` was refused 422, and the
743+
// group then vanished from every form. No entry-level change. MEASURED
744+
// first: the repository ships 8 field groups on 4 objects (examples:
745+
// app-showcase `contact` 4 and `semantic_zoo` 2; the `create-objectstack`
746+
// blank template and the `os init` scaffold 1 each; platform objects
747+
// none) and none carries a `visibleWhen` → 0 findings at either door,
748+
// against a refusal at each for the card's body.
749+
//
736750
// [#24677] The object pass also refuses a `lookup` / `master_detail`
737751
// whose `reference` is a stored-metadata-body table — the declaration the
738752
// #24316 hop refusal presupposes, which the engine never hydrates. No
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26646 — the build and the OBJECT write door judge a field group's
5+
* `visibleWhen`.
6+
*
7+
* ## The state this closes
8+
*
9+
* `ObjectFieldGroupSchema.visibleWhen` is a live slot: the form renderer gates
10+
* the whole group on it, header included, and a predicate that faults hides
11+
* the group (fail-closed). No pass of `validateStackExpressions` read it, so
12+
* the card's body — `record.no_such_field == 'x'` on a group — saved with a
13+
* 200 and the group vanished from every form, while the same text on a
14+
* field's `visibleWhen` or an action's `visible` was refused 422 at the same
15+
* door.
16+
*
17+
* ## The crossing
18+
*
19+
* No registry change: the entry already declares `object`. The field-group
20+
* pass runs in the object walk, after the field walk, with the field
21+
* `visibleWhen` slot's verdict (the `record`-scoped check and the root
22+
* verdict). It is not fenced on an object write, so the door's finding IS the
23+
* build's finding — rule, location, message and hint — located at the group's
24+
* key.
25+
*
26+
* The protocol-level half — the same verdict through the real `saveMetaItem` —
27+
* is the #26646 block of
28+
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
29+
*/
30+
import { describe, expect, it } from 'vitest';
31+
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
32+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
33+
34+
/**
35+
* The card's probe object, verbatim but for the predicate: `stage` sits in
36+
* group `g1`, whose `visibleWhen` is the body under test.
37+
*/
38+
const fxGroup = (visibleWhen: unknown, name = 'qa_fg_probe') => ({
39+
name,
40+
label: 'QA',
41+
sharingModel: 'private',
42+
fields: {
43+
name: { type: 'text', label: 'Name' },
44+
stage: { type: 'text', label: 'Stage', group: 'g1' },
45+
},
46+
fieldGroups: [{ key: 'g1', label: 'G1', visibleWhen }],
47+
});
48+
49+
const WHERE = "object 'qa_fg_probe' · field group 'g1' visibleWhen";
50+
51+
/** The card's body: a read of a field the object does not declare. */
52+
const CARD_BODY = "record.no_such_field == 'x'";
53+
54+
/**
55+
* One refused body per finding the pass gives. Each `subject` is the named
56+
* subject of the build's finding (what the author typed), not its prose.
57+
*/
58+
const REFUSED = [
59+
{ body: CARD_BODY, subject: 'unknown field `no_such_field`' },
60+
{ body: "stage == 'won'", subject: 'bare reference `stage`' },
61+
{ body: 'sqrt(record.stage) > 1', subject: '`sqrt`' },
62+
{ body: 'record.stage ==', subject: 'invalid CEL predicate' },
63+
// The root verdict: a root the field-rule family never binds.
64+
{ body: "'org_admin' in current_user.positions", subject: 'reads `current_user`' },
65+
] as const;
66+
67+
/** Bodies the build accepts on a field's `visibleWhen`, and so must accept on a group. */
68+
const ACCEPTED = [
69+
"record.stage == 'won'",
70+
"record.stage != null && record.name != ''",
71+
"previous.stage != record.stage",
72+
] as const;
73+
74+
const gateObject = (item: unknown, objects: unknown[] = []) =>
75+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects } });
76+
77+
const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
78+
fs.filter((f) => f.rule === EXPRESSION_INVALID);
79+
80+
const buildFindings = (...objects: unknown[]) => {
81+
const stack = { objects };
82+
return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack }));
83+
};
84+
85+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
86+
87+
describe('#26646 — the object door gives the build\'s field group `visibleWhen` verdict', () => {
88+
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
89+
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
90+
});
91+
92+
for (const { body, subject } of REFUSED) {
93+
it(`⭐ LIT — a group's \`visibleWhen: ${body}\` is REFUSED, located at the group's key`, () => {
94+
const result = gateObject(fxGroup(body));
95+
96+
expect(result.rulesRun).toContain('validateStackExpressions');
97+
const errs = expressionFindings(result.errors);
98+
expect(errs, dump(result)).toHaveLength(1);
99+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
100+
expect(errs[0]!.message).toContain(subject);
101+
});
102+
}
103+
104+
for (const body of ACCEPTED) {
105+
it(`⭐ CONTROL — a group's \`visibleWhen: ${body}\` still publishes clean, and the build agrees`, () => {
106+
const result = gateObject(fxGroup(body));
107+
108+
expect(result.rulesRun).toContain('validateStackExpressions');
109+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
110+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
111+
expect(buildFindings(fxGroup(body))).toEqual([]);
112+
});
113+
}
114+
115+
it('⭐ CONTROL — a group with no `visibleWhen` publishes clean', () => {
116+
const result = gateObject(fxGroup(undefined));
117+
118+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
119+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
120+
});
121+
122+
it('⭐ PARITY — the card\'s body earns the same finding on the group as on the field\'s own `visibleWhen`', () => {
123+
const onGroup = expressionFindings(gateObject(fxGroup(CARD_BODY)).errors);
124+
const onField = expressionFindings(
125+
gateObject({
126+
...fxGroup(undefined),
127+
fields: { ...fxGroup(undefined).fields, stage: { type: 'text', label: 'Stage', group: 'g1', visibleWhen: CARD_BODY } },
128+
}).errors,
129+
);
130+
131+
expect(onField.map((f) => f.where)).toEqual(["object 'qa_fg_probe' · field 'stage' visibleWhen"]);
132+
expect(onGroup.map((f) => f.message), dump(onGroup)).toEqual(onField.map((f) => f.message));
133+
});
134+
135+
it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => {
136+
for (const { body } of REFUSED) {
137+
const atBuild = buildFindings(fxGroup(body));
138+
const atDoor = expressionFindings(gateObject(fxGroup(body)).errors);
139+
140+
// Non-vacuous: the build refuses each of them.
141+
expect(atBuild.length, body).toBeGreaterThan(0);
142+
expect(atDoor, body).toEqual(atBuild);
143+
}
144+
});
145+
146+
it('the root verdict is worded for the group, and names its fail-closed consequence', () => {
147+
const errs = expressionFindings(gateObject(fxGroup(REFUSED[4].body)).errors);
148+
149+
expect(errs, dump(errs)).toHaveLength(1);
150+
expect(errs[0]!.message).toMatch(/^`visibleWhen` reads `current_user`, but a field group's section predicate binds only `record`/);
151+
expect(errs[0]!.message).toContain('fails CLOSED');
152+
// The prescription is the group's: field-level security or a `record`
153+
// rewrite, never the select-option move a field's message offers.
154+
expect(errs[0]!.message).toContain('field-level security');
155+
expect(errs[0]!.message).not.toContain('CHOICES of a select');
156+
});
157+
158+
it('a stored sibling\'s broken group is not this write\'s to answer for (the differential)', () => {
159+
const sibling = fxGroup(CARD_BODY, 'qa_fg_sibling');
160+
// Non-vacuous: the sibling is refused at the build.
161+
expect(buildFindings(sibling).length).toBeGreaterThan(0);
162+
163+
const result = gateObject(fxGroup(ACCEPTED[0]), [sibling]);
164+
165+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
166+
});
167+
});

‎packages/lint/src/runtime-gate.object-formula-writes.test.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
* (its pins: `runtime-gate.object-validation-writes.test.ts`), since its
2323
* pass 2 the field-rule slots (`runtime-gate.object-field-rule-writes.test.ts`),
2424
* since its pass 3 the per-option `visibleWhen`
25-
* (`runtime-gate.object-option-visibility-writes.test.ts`), and since its
25+
* (`runtime-gate.object-option-visibility-writes.test.ts`), since its
2626
* pass 4 the object's own action predicates
27-
* (`runtime-gate.object-action-predicate-writes.test.ts`). Every pass over the
27+
* (`runtime-gate.object-action-predicate-writes.test.ts`), and since #26646 a
28+
* field group's `visibleWhen`
29+
* (`runtime-gate.object-field-group-visibility-writes.test.ts`). Every pass over the
2830
* object's own body now judges here; the passes over the stack's OTHER
2931
* collections (flows, top-level actions, sharing rules, hooks) do not. Both
3032
* halves are pinned below with the build still flagging the same body, so a
@@ -161,8 +163,9 @@ describe('#22019 / #22032 — every pass over the object\'s body judges on this
161163
/**
162164
* One body carrying a fault in EVERY pass over the object's own body — the
163165
* validation-rule pass (#22032 pass 1), a field-rule slot (`requiredWhen`,
164-
* pass 2), an option's `visibleWhen` (pass 3) and an object action's
165-
* `visible` and `disabled` (pass 4) — beside a CLEAN formula. Since pass 4 no
166+
* pass 2), an option's `visibleWhen` (pass 3), an object action's
167+
* `visible` and `disabled` (pass 4) and a field group's `visibleWhen`
168+
* (#26646) — beside a CLEAN formula. Since pass 4 no
166169
* object-borne site is fenced: the build and the object door flag the same
167170
* sites in the same order. Each fault is one the build refuses at `error`,
168171
* so a fence put back on any of these passes drops its site from the door's
@@ -182,15 +185,17 @@ describe('#22019 / #22032 — every pass over the object\'s body judges on this
182185
options: [{ label: 'Gold', value: 'gold', visibleWhen: 'amount > 1' }],
183186
},
184187
},
188+
fieldGroups: [{ key: 'g1', label: 'G1', visibleWhen: 'amount > 1' }],
185189
actions: [
186190
{ name: 'fx_close', label: 'Close', type: 'script', target: 'close', visible: 'record.status ==', disabled: 'amount > 1' },
187191
],
188192
});
189-
/** Every object-borne site of the body (passes 1–4), by the build's `where`, in the build's order. */
193+
/** Every object-borne site of the body (passes 1–4 and #26646), by the build's `where`, in the build's order. */
190194
const LIFTED_SITES = [
191195
"object 'fx_sqrt' · validation 'amount_root'",
192196
"object 'fx_sqrt' · field 'name' requiredWhen",
193197
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
198+
"object 'fx_sqrt' · field group 'g1' visibleWhen",
194199
"object 'fx_sqrt' · action 'fx_close' visible",
195200
"object 'fx_sqrt' · action 'fx_close' disabled",
196201
];
@@ -232,7 +237,7 @@ describe('#22019 / #22032 — every pass over the object\'s body judges on this
232237
const result = gateObject(withEverySite());
233238

234239
expect(result.rulesRun).toContain('validateStackExpressions');
235-
// [#22032 passes 1–4] Every lifted pass's finding, in the build's order, and nothing else.
240+
// [#22032 passes 1–4, #26646] Every lifted pass's finding, in the build's order, and nothing else.
236241
expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual(LIFTED_SITES);
237242
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
238243
});
@@ -244,9 +249,9 @@ describe('#22019 / #22032 — every pass over the object\'s body judges on this
244249
// in its own walk order (flows, the object's body, actions, sharing rules, hooks).
245250
expect(all.map((i) => i.where), dump(all)).toEqual([
246251
OTHER_SITES[0],
247-
...LIFTED_SITES.slice(0, 3),
252+
...LIFTED_SITES.slice(0, 4),
248253
OTHER_SITES[1],
249-
...LIFTED_SITES.slice(3),
254+
...LIFTED_SITES.slice(4),
250255
OTHER_SITES[2],
251256
OTHER_SITES[3],
252257
]);

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest';
77
// the root the rule never saw.
88
import { SCOPE_ROOTS, buildScope, ExpressionEngine } from '@objectstack/formula';
99
import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
10-
import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
10+
import { FieldSchema, ObjectFieldGroupSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
1111
import { EvalUserSchema } from '@objectstack/spec/identity';
1212
import { SharingRuleSchema } from '@objectstack/spec/security';
1313
import { BulkActionDefSchema } from '@objectstack/spec/ui';
@@ -3349,7 +3349,8 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
33493349
// attachments, whose block names join the field index.
33503350
// `listViews` joined with #24798: an object's own list views carry bulk
33513351
// defs whose `confirmText` is judged.
3352-
expected: ['actions', 'attachedOnRead', 'fields', 'listViews', 'name', 'validations'],
3352+
// `fieldGroups` joined with #26646: each group's section predicate.
3353+
expected: ['actions', 'attachedOnRead', 'fieldGroups', 'fields', 'listViews', 'name', 'validations'],
33533354
declaredBy: 'ObjectSchema',
33543355
keys: () => Object.keys(ObjectSchema.shape),
33553356
},
@@ -3435,6 +3436,14 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
34353436
declaredBy: 'SelectOptionSchema',
34363437
keys: () => Object.keys(SelectOptionSchema.shape),
34373438
},
3439+
{
3440+
// [#26646] One `fieldGroups[]` entry: its section predicate, and its
3441+
// `key` for the location.
3442+
receiver: 'fieldGroup',
3443+
expected: ['key', 'visibleWhen'],
3444+
declaredBy: 'ObjectFieldGroupSchema',
3445+
keys: () => shapeKeysOf(ObjectFieldGroupSchema),
3446+
},
34383447
{
34393448
// [#22228] A field's cell formatting rule — the list view's own rule
34403449
// element, mounted on `FieldSchema.conditionalFormatting`. `condition` is

0 commit comments

Comments
 (0)