Skip to content

Commit bb4f5cc

Browse files
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) (#22268)
Fixes #22157 Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) ## What changes A select option's `visibleWhen` is a gate the server enforces on write. The server's option check (`evaluateOptionVisibility` in `packages/objectql/src/validation/rule-validator.ts`) binds `record`, `previous` and the acting user, and nothing else. An option predicate that read `parent.status == 'closed'`, on a detail with exactly one `master_detail`, passed `os build` and the object save door. Then every write that picked the option faulted (`Unknown variable: parent`) and was admitted unchecked. - **The option loop gains a root verdict.** `optionVisibleWhenRootIssue` in `packages/lint/src/validate-expressions.ts` is an allowlist of what the option check binds: `record`, `previous`, and the acting user as `current_user` with its ADR-0068 aliases `user`, `ctx` and `os`. Every other `SCOPE_ROOTS` member is refused at `error`, `parent` included. The finding is located at the option slot (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. For `parent` it says that only a field's own `readonlyWhen` / `requiredWhen` binds that root, and it gives the denormalise-onto-the-detail remedy. - **One change covers both doors.** The object save door runs this same pass (`runStackExpressionPasses`, since the #22032 pass-3 crossing), so the door's finding is the build's finding. - **The runtime is unchanged (ruling).** `evaluateOptionVisibility` still binds no `parent`. The constant's docblock says a root joins the list only in the change that binds it there. - **Why the membership test is `SCOPE_ROOTS`, not the field-rule's judged vocabulary.** The gap is exactly the set of roots the strict env declares. A root it does not declare, such as `app`, is already refused on this slot by the bare-reference check, so judging it here would give one mistake two verdicts. The existing #13935 pin ("`app` there keeps the bare-reference verdict") is untouched and green. Because the judged roots are all declared, `check` never reports a bare reference to them, so the two verdicts stay disjoint by construction. ## The dispatch's hypotheses, measured - **H1 held.** At base `73a0a6bf1d`, through the built `@objectstack/lint`, the body `parent.status == 'closed'` on a one-master detail gave 0 findings from `runAuthoringRules('build')` and 0 door errors from `runRuntimeAuthoringRules({ type: 'object' })`. So did `input.x == 1` and `data.x == 1`. The option loop had no root verdict, and `parent` is a `SCOPE_ROOTS` member, so the bare-reference check stayed silent. - **H2 held, read off the call.** `evaluateOptionVisibility` calls `ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions })`. `buildScope` turns that into `record`, `previous` (when defined) and, when the write carries a user, the same `EvalUser` under `current_user`, `user`, `ctx.user` and `os.user`. In the expression env the acting user is spelled `current_user` (ADR-0068 D1's canonical root); `user`, `ctx` and `os` are aliases. `permissions` mounts no root: it reaches `can` through the environment. The allowlist is exactly those six bare roots, the same root-granularity reading as the runtime's own `USER_SCOPE_ROOTS` next to the evaluator. - **H3 held: corpus first, stop condition not met.** Corpus A is every `*.object.ts` under `packages/**` and `examples/**` (112 files) plus the two `app-multi-package` sub-stacks: 119 objects in 18 groups, 0 import or parse failures. Corpus B is the example stacks as `defineStack` composes them: 33 objects in 5 groups. Both carry the same 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `'org_admin' in current_user.positions` gate. Their roots are `record` ×4 and `current_user` ×1. Option findings: 0 at the build and 0 at the door (errors and advisories), at both shapes, at base `73a0a6bf1d` and at head `48b9329137`. Positive control in the same harness: the `parent` body gave build 0 and door 0 at base, and build 1 and door 1 at head. - **H4 held.** The new protocol block was run against the base build of `@objectstack/lint` (`73a0a6bf1d`). Through the real `saveMetaItem`, with the header in the registry, the publish save of the measured body resolved `{ success: true }`, so pin (a) went red, and (d) went red with 0 build findings. (b), as first written with the draft save alone, and (c) were green. The promotion and package-publish legs of (b) were added later; the ablation below covers them. With the verdict built, the same save answers 422 `INVALID_METADATA`, because the door runs the same pass. ## Pins (Zone 3) - **Build side** (`validate-expressions.test.ts`, new describe `#22157`): - `parent.status == 'closed'` on a one-master detail is refused at `error`, at the option slot, with a message naming the option, the field and the root. - CONTRAST: the same `parent` read on the field's own `readonlyWhen` passes. - CONTROL: `record`, `previous`, `current_user`, `user`, `ctx.user`, `os.user`, `current_user.can(...)` and a `record` member spelled like the root (`record.parent_code`) all pass. - Every `SCOPE_ROOTS` member outside the allowlist is refused, one finding each. The table is generated from the real `SCOPE_ROOTS`. - **Door side, lint** (`runtime-gate.object-option-visibility-writes.test.ts`): LIT (refused at the door, located at the option), PARITY (the door's finding equals the build's), and CONTROL for `record.x` and the `current_user` role gate. - **Door side, protocol** (`protocol.runtime-authoring-gate.test.ts`, new `#22157` block, through the real methods): - (a) A publish save answers 422 `INVALID_METADATA`, with one `expression-invalid` issue at the option, and nothing lands. - (b) A draft save is allowed and lands as `draft`. Its promotion through `publishMetaItem` is refused 422, and a package draft publish answers `refused` with 0 published. - (c) Control: `record.x == 'a'` saves and lands `active`. - (d) PARITY: `rule`, `where`, `path`, `message` and `hint` are equal at the door and at the build. ## Reverse verification (ablation) The run was made from the committed head `012e8d7dbe` through `scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap` restore on EXIT, INT and TERM against the absolute path. - **Mutation.** The verdict call was gated on `Reflect.has(Object, 'ablation22157')`, which is always false. The anchor went x1 to x0 and the blob went `9781066c15f0` to `570d3d5df714`. - **Prediction, recorded before the run.** The lint source suites go 5 red: build refuses `parent`, the generated table, the two-roots case, door LIT and door PARITY. Everything else stays green. The protocol file goes 4 red, (a), both (b) refusals and (d), with (c) and every other block green. - **Observed.** Lint: 5 failed, 371 passed, the predicted five. `@objectstack/lint` was then rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. Protocol: 4 failed, 115 passed, the predicted four. - **Restore.** The blob is `9781066c15f0`, equal to HEAD, and `git diff HEAD` is empty. After a rebuild, `--absent` found the marker gone from all 14 built files and the whole tree clean. Lint went back to 376 of 376 and the protocol file to 119 of 119. ## Local verification (at `c6d1020b90`, the merge of `origin/main` `b460153912`) - The merge changed none of this PR's lines. For each of the 5 files, `git patch-id --stable` of the diff from the merge base is equal at `012e8d7dbe` (base `73a0a6bf1d`) and at `c6d1020b90` (base `b460153912`). - `pnpm --filter @objectstack/lint test`: 128 files, 5862 tests passed. - `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` and `check:test-typecheck` both OK. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.runtime-authoring-gate.test.ts`: 119 passed. - `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. Its `tsconfig.json` includes `src/**/*` and excludes only `node_modules` and `dist`, so the test file is in the program. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `c6d1020b90` derived the same 63 commands as at claim time. 62 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET: 68 packages unrelated to this diff have no `dist/` in the local worktree. **NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree.** The `--ran` reconciliation reads 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN. - ESLint, narrowed to the 4 changed `.ts` files with `--no-inline-config --format json`, gave 4 files, 0 errors and 0 warnings. The population was read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus the build-dir ignores. That config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any file it does not touch. The repo-wide `pnpm lint` is CI's. ## Grade and changeset - `.changeset/22157-option-visible-when-parent.md` lists `@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It has `fix(lint)!`, the Clause-② line above, a BREAKING section with the remedy, and the ADR-0087 disposition `not-required (no-migration-prescription)`. `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing]`. - **Why `minor`, not triage's `patch`.** AGENTS.md says "`(narrowing)` is BREAKING". This is the shape of the #22032 pass changesets. `@objectstack/metadata-protocol` is listed as those passes listed it: no code moves there, but its save, promotion and package-publish doors are where the breaking behaviour shows. - `.changeset/pre.json` is present on `origin/main`, in pre mode `next`. The bump is still `minor`, and `check-changeset-no-major` is green. - **Built declarations.** One doc comment moves in `@objectstack/lint`'s `index.d.ts`: the docblock above the exported `FIELD_RULE_BOUND_ROOTS` gains one sentence pointing at the option verdict. No export or signature moves, because the new constant and function are module-private. ## File surface All five files are inside the claim's surface: `packages/lint/src/validate-expressions.ts`, its tests in `packages/lint/src/` (`validate-expressions.test.ts` and `runtime-gate.object-option-visibility-writes.test.ts`), `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, and `.changeset/22157-option-visible-when-parent.md`. `rule-validator.ts` was read, not edited. The diff is +449/-6 lines. ## Acceptance notes - **A residual in the same family, measured and reported to the PM, not fixed here.** The allowlist is root-granular, as the ruling asked. At the option site, `ctx` and `os` carry only their `user` member: `evaluateOptionVisibility` passes no `org` or `env`. - At head `012e8d7dbe`, an option `visibleWhen` of `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passes the build and the door with 0 findings. - Through the built `evaluateValidationRules` with an authenticated caller, `os.org.id != ''` and `ctx.locale == 'en'` are admitted with `predicate-fault` (`No such key: org`, `No such key: locale`). `os.env` was not run. - A member-level verdict needs its own shape decision, so it goes to the family's closing card. - An option `visibleWhen` reading `app` keeps the bare-reference message, which prescribes the `record`-qualified rewrite that #13935 removed on the field-rule slots. It is still refused at `error`, so only the prose is affected. Carrier: none. - Observed, not measured: `validate-expressions.ts` never walks a picklist's own option `visibleWhen` (the `picklist` metadata type). Whether the server's option check ever evaluates those predicates was not read. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 345d3f3 commit bb4f5cc

5 files changed

Lines changed: 449 additions & 6 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157)
7+
8+
Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind)
9+
10+
A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted.
11+
12+
The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way.
17+
- An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
18+
19+
**Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
20+
21+
**Unchanged.**
22+
23+
- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change.
24+
- The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before.
25+
- A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message.
26+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged.
27+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
28+
- Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it.
29+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`.
30+
31+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build` and at the object save door of a select option's visibleWhen predicate that reads a root the server's option check does not bind: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose option predicate is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate against a bound root, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,69 @@ describe('#22032 pass 3 — the object door gives the build\'s option `visibleWh
181181
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
182182
});
183183
});
184+
185+
/**
186+
* #22157 — the option's root verdict, at the object door.
187+
*
188+
* The server's option check (`evaluateOptionVisibility`) binds `record`,
189+
* `previous` and the acting user. An option `visibleWhen` reading `parent` (on
190+
* a detail with exactly one `master_detail`, where the field-rule slots DO
191+
* bind it) published clean through this door and then faulted open on every
192+
* write that picked the option. The verdict lives in the build's option pass,
193+
* which this door runs since #22032's pass 3, so the door's finding is the
194+
* build's. The protocol-level half, through the real `saveMetaItem`, is the
195+
* #22157 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
196+
*/
197+
describe('#22157 — the object door refuses an option `visibleWhen` reading `parent`, as the build does', () => {
198+
const header = {
199+
name: 'fx_header',
200+
label: 'Header',
201+
sharingModel: 'private',
202+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
203+
};
204+
const line = (visibleWhen: unknown) => ({
205+
name: 'fx_line',
206+
label: 'Line',
207+
sharingModel: 'private',
208+
fields: {
209+
hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' },
210+
x: { type: 'text', label: 'X' },
211+
tier: {
212+
type: 'select',
213+
label: 'Tier',
214+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
215+
},
216+
},
217+
});
218+
const LINE_WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
219+
const PARENT = "parent.status == 'closed'";
220+
221+
it('⭐ LIT — the measured body is REFUSED at the door, located at the option, naming the root', () => {
222+
const result = gateObject(line(PARENT), [header]);
223+
224+
expect(result.rulesRun).toContain('validateStackExpressions');
225+
const errs = expressionFindings(result.errors);
226+
expect(errs, dump(result)).toHaveLength(1);
227+
expect(errs[0]).toMatchObject({ severity: 'error', where: LINE_WHERE, path: LINE_WHERE });
228+
expect(errs[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
229+
});
230+
231+
it('⭐ PARITY — the door finding IS the build finding', () => {
232+
const atBuild = buildFindings(header, line(PARENT));
233+
const atDoor = expressionFindings(gateObject(line(PARENT), [header]).errors);
234+
235+
// Non-vacuous: the build refuses it.
236+
expect(atBuild, dump(atBuild)).toHaveLength(1);
237+
expect(atDoor, dump(atDoor)).toEqual(atBuild);
238+
});
239+
240+
for (const body of ["record.x == 'a'", "'org_admin' in current_user.positions"]) {
241+
it(`⭐ CONTROL — \`${body}\` on the same option publishes clean, and the build agrees`, () => {
242+
const result = gateObject(line(body), [header]);
243+
244+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
245+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
246+
expect(buildFindings(header, line(body))).toEqual([]);
247+
});
248+
}
249+
});

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,6 +2005,89 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
20052005
});
20062006
});
20072007

2008+
/**
2009+
* ── The option's root verdict (#22157) ──────────────────────────────────
2010+
*
2011+
* The server's option check (`evaluateOptionVisibility`) binds `record`,
2012+
* `previous` and the acting user, and nothing else. A predicate reading any
2013+
* other root faults on every write that picks the option and is admitted
2014+
* unchecked, so the build refuses it. The measured body is `parent` on a
2015+
* detail with exactly one `master_detail`: the object shape on which the
2016+
* field-rule slots one level up DO bind `parent`, so it is the shape an
2017+
* author copies from.
2018+
*/
2019+
describe('a per-option `visibleWhen` root the option check does not bind is refused (#22157)', () => {
2020+
const detail = (visibleWhen: unknown, readonlyWhen?: unknown) => ({
2021+
objects: [
2022+
{ name: 'fx_header', fields: { status: { type: 'text' } } },
2023+
{
2024+
name: 'fx_line',
2025+
fields: {
2026+
hdr: { type: 'master_detail', reference: 'fx_header' },
2027+
x: { type: 'text', ...(readonlyWhen === undefined ? {} : { readonlyWhen }) },
2028+
parent_code: { type: 'text' },
2029+
tier: {
2030+
type: 'select',
2031+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
2032+
},
2033+
},
2034+
},
2035+
],
2036+
});
2037+
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
2038+
const PARENT = "parent.status == 'closed'";
2039+
/** What `evaluateOptionVisibility` binds, read off its call (`rule-validator.ts`). */
2040+
const BOUND = ['record', 'previous', 'current_user', 'user', 'ctx', 'os'];
2041+
2042+
it('⭐ refuses `parent` at error, located at the option, naming the option, the field and the root', () => {
2043+
const issues = validateStackExpressions(detail(PARENT));
2044+
expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1);
2045+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: PARENT });
2046+
expect(issues[0]!.message).toContain("option 'gold' on field 'tier' reads `parent`");
2047+
});
2048+
2049+
it('⭐ CONTRAST — the same `parent` read on the field\'s own `readonlyWhen` passes: that slot binds it', () => {
2050+
expect(validateStackExpressions(detail("record.x == 'a'", PARENT))).toEqual([]);
2051+
});
2052+
2053+
it('⭐ CONTROL — `record`, `previous` and the acting user under every ADR-0068 spelling pass', () => {
2054+
for (const body of [
2055+
"record.x == 'a'",
2056+
"previous.x == 'a'",
2057+
"'org_admin' in current_user.positions",
2058+
"'org_admin' in user.positions",
2059+
"ctx.user.id != ''",
2060+
"os.user.id != ''",
2061+
"current_user.can('fx_line', 'edit')",
2062+
// A `record` member merely spelled like the refused root.
2063+
"record.parent_code == 'a'",
2064+
]) {
2065+
expect(validateStackExpressions(detail(body)), body).toEqual([]);
2066+
}
2067+
});
2068+
2069+
/**
2070+
* An ALLOWLIST read against the real `SCOPE_ROOTS`, never a copy of it:
2071+
* every platform-wide root outside what the option check binds is
2072+
* refused, one finding each, so a root added to the baseline later is
2073+
* covered the day it lands.
2074+
*/
2075+
it('refuses every `SCOPE_ROOTS` member the option check does not bind, one finding each', () => {
2076+
const unbound = (SCOPE_ROOTS as readonly string[]).filter((r) => !BOUND.includes(r));
2077+
expect(unbound).toContain('parent');
2078+
for (const root of unbound) {
2079+
const issues = validateStackExpressions(detail(`${root}.k == 'a'`));
2080+
expect(issues, root).toHaveLength(1);
2081+
expect(issues[0]!.where, root).toBe(WHERE);
2082+
expect(issues[0]!.message, root).toContain(`reads \`${root}\``);
2083+
}
2084+
});
2085+
2086+
it('gives one finding when the predicate reads two unbound roots', () => {
2087+
expect(validateStackExpressions(detail(`${PARENT} && input.k == 1`))).toHaveLength(1);
2088+
});
2089+
});
2090+
20082091
it('flags a bare-field sharing-rule condition', () => {
20092092
const issues = validateStackExpressions({
20102093
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],

0 commit comments

Comments
 (0)