Skip to content

Commit f2a45db

Browse files
fix(lint)!: the object save door gives the build's field-rule-slot verdict (#22032 pass 2) (#22117)
Part of #22032 Clause-②: no (narrowing) This is pass 2 of #22032: the field-rule slots. Passes 3 and 4 stay fenced, and the card stays open for them: option `visibleWhen`, and the object's action predicates. ## What changes **The object save door gives the build's verdict on a field's rule slots.** `formulas.mdx` says "the same `validateExpression` validator backs `os build` and metadata registration". After pass 1 the object door judged formula fields and validation-rule predicates, and fenced the field-rule slots off by name. So an object whose field carried a bare `requiredWhen: 'amount > 1'` (the card's measured body) still saved with a 200, while `os build` refused it at error. - **The change is in the fence, not the registry.** In `runStackExpressionPasses` (`packages/lint/src/validate-expressions.ts`) the field walk no longer starts with `if (objectWrite) { judgeFieldFormula(fname, f); continue; }`. On an object write the walk now runs, at the build's own position: - the four slots (`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`) as `record`-scoped predicates, with the root verdict; - the `parent` gate (a `readonlyWhen` / `requiredWhen` reading `parent` on an object without exactly one `master_detail`); - the null-guard check over `requiredWhen`; - the refusal of a `requiredWhen` / `readonlyWhen` read through a reference field; - the formula pass, unchanged. - **Pass 3 stays fenced by its own guard.** H1 held: the removed `continue` also skipped the per-option `visibleWhen` loop, which sits inside the same iteration. That loop now reads `(objectWrite ? [] : recordsOf(f.options))`, the same guard shape as the flow, action, sharing-rule and hook loops. Deleting the `continue` alone would have lifted passes 2 and 3 together. - **No registry change (H3 re-verified).** The `validateStackExpressions` entry declares `runtimeTypes: ['flow', 'action', 'hook', 'object']` (`authoring-rules.ts`), and `runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it. `runtime-gate.ts` is untouched. - **Docblocks made true (H2).** `StackExpressionOptions.runtimeWriteType` now names three admitted passes and two fenced ones. `AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one line that reaches a built `.d.ts`, names the field-rule-slot pass. The function-head comment, the `judgeFieldFormula` docblock, the registry entry's measurement comment and the object roster comment in `runtime-gate.object-writes.test.ts` move with it. - **The door's verdict is the build's finding (H4).** The door's 422 issue and `runAuthoringRules('build', …)` give the same rule (`expression-invalid`), location (`object 'fx_field' · field 'name' requiredWhen`), path, message and hint. The pins compare these key by key. - **No code change in `packages/metadata-protocol`.** Only its test file gains the door-level pins. ## Pins - **Lint door:** `packages/lint/src/runtime-gate.object-field-rule-writes.test.ts` (new, 12 tests). - LIT, one refused body per slot and per gate: a bare `requiredWhen`, an unregistered function in `readonlyWhen` and in `visibleWhen`, a bare `conditionalRequired`, the root verdict (`current_user`), the `parent` gate, the `requiredWhen` null guard, and the traversal refusal (`record.account.name`). Each is located at the slot and asserted on its named subject. - CONTROL: valid predicates on every slot, and a `parent`-scoped detail with its master stored beside it, are clean at the door and at the build. - PARITY: for each refused body, the door's findings equal the build's. - The differential: a stored sibling's broken field rules are not this write's to answer for. - **The fence (enumeration pin):** in `packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The fenced sites are now passes 3 and 4 only (an option `visibleWhen`, an action `visible`). The lifted sites are the validation rule and the `requiredWhen`. The build flags all four; the object door flags the two lifted sites, in the build's order, and `runStackExpressionPasses` on an object write returns exactly the build's findings for the admitted passes. - **Protocol door:** a new pass-2 block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem`, `publishMetaItem` and `publishPackageDrafts`: - (a) a bare `requiredWhen`, an unregistered function in `visibleWhen`, and a `parent` read with no master in `readonlyWhen` are each refused on an active save with a 422 `INVALID_METADATA` carrying the build's located finding, and nothing lands; - (a) the card's body is refused on a draft's promotion, and on a package draft publish (`outcome: 'refused'`, `failed` naming the object with `INVALID_METADATA`, the row left a draft); the draft saves themselves still succeed; - (b) valid predicates on the three slots still save, and the row lands active; - (d) for each refused body, the door and `os build` give the same finding on rule, where, path, message and hint. ## Reverse verification (one-off, from committed HEAD `ab17f41aa`) - **What was mutated.** `scripts/ablation-replace.mjs --hold` put the fence back at the head of the field loop: `const ablationFence22032 = objectWrite; if (ablationFence22032) { judgeFieldFormula(fname, f); continue; }`. The anchor was hit once, 1 to 0, and the blob went `758396f8e4e4` to `0f007b9ed735`. The script carried `trap restore EXIT INT TERM`. - **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - **Lint suites (source): 11 failed, 18 passed, as predicted.** Red: the 8 LIT tests, PARITY, and the two fence tests that assert the lifted sites. Green: the registry test, CONTROL, the differential, the build-flags-each-site test, the six formula-door tests and the eight pass-1 tests. - **Protocol pass-2 block (dist-mediated): 6 failed, 1 passed, as predicted.** Red: the three (a) saves, (a) on promotion, (a) on package publish, and (d). Green: (b). - **Restore.** The tool restored the file: blob `758396f8e4e4` equals HEAD, and `git diff HEAD` is empty. Lint was rebuilt, and `--absent` found the marker gone from all 14 built files with a clean tree. Both suites went green again: lint 29 of 29, and the protocol file 86 of 86. ## Measurements - **Corpus first: the stop condition was not met.** Every object this tree ships was judged before the door changed: every `*.object.ts` under `packages/**` and `examples/**` (111 files), plus the two `app-multi-package` sub-stacks. That is 118 objects in 18 groups, at the raw shape and at the `ObjectSchema.parse` shape, each with its own group as context. - 9 field-rule slots on 8 fields of 3 objects: `showcase_invoice` 4 (`issued_on.requiredWhen`, `tax_rate.readonlyWhen`, `paid_on.requiredWhen`, `paid_on.visibleWhen`), `showcase_invoice_line` 4 (`product`, `quantity` and `unit_price` `readonlyWhen` read `parent`; `description.requiredWhen`), and `sys_permission_set.name.readonlyWhen`. - At base `bafb58bb0`: 0 build errors and 0 build warnings for the pass (raw, parsed, and through `runAuthoringRules('build')`), and 0 door findings. - At head: 0 door errors and 0 door advisories over every object, through `runRuntimeAuthoringRules` with type `object`, at both shapes. - The card's body, as a positive control in the same harness: 1 build error and 1 door error. - **Which doors newly answer 422 (H5).** The active publish save, a draft's promotion, and a package draft publish, measured through the real methods above. A draft save stays ungated, measured by the same pins. - **`conditionalRequired` cannot reach this gate through the save door.** Measured through the real `saveMetaItem` with a scratch test that was deleted afterwards: the per-type spec step refuses it as a key retired in protocol 17 (422 `INVALID_METADATA`, on a draft save and on a publish save) before the gate runs. The lint pin judges it because the build does. ## Clause-② (measured) - **Accept set: narrowing.** An object write in publish mode answered 200 for a field-rule slot the validator refuses. It now answers 422 on the three doors above. - **Built entry declarations.** In `@objectstack/lint` one doc comment moves (`AuthoringRuleContext.runtimeWriteType`). `StackExpressionOptions` and `runStackExpressionPasses` are not in the built declarations. No exported signature moves. - **Changeset.** `.changeset/22032-object-save-door-field-rule-slots.md` covers `@objectstack/lint` and `@objectstack/metadata-protocol`: `minor`, `fix(lint)!`, BREAKING, with the remedy, and ADR-0087 `not-required (no-migration-prescription)`. `.changeset/pre.json` is absent on `origin/main` `bafb58bb0`, so `minor` with the BREAKING banner, as pass 1. ## Tests and gates (all at `ab17f41aa`) - `@objectstack/lint`: 122 files, 5658 tests passed; `typecheck` exit 0, its test-typecheck included (`--listFiles` shows the three touched lint test files in the `tsconfig.test.json` program). - `@objectstack/metadata-protocol`: 221 files passed and 3 skipped; 28234 tests passed and 19 skipped; `typecheck` exit 0 (`--listFiles` shows the door test file in the program). - **Consumer readings, against a rebuilt `rest^...` and `objectql^...` closure.** - `@objectstack/rest`: every `meta-object-*` file and `meta-publish-package-scope`, 11 files, 197 tests passed. - `@objectstack/objectql`: `save-meta-response-conformance`, `publish-meta-response-conformance` and `plugin.integration`, 3 files, 67 tests passed. - No other test fixture saves a field-rule slot through a save door: every test file carrying one of the four slot keys was grepped against the door entry points, and the hits are the lint and metadata-protocol files above, whose suites are green. - **Gates.** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands at this head. All 63 end at exit 0, and `--ran` reconciles 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, with an exit code recorded for each. - `check-plugin-teardown-shape.mjs --self-test` first exited 3: its pinned fixture commit was outside this shallow clone. After `git fetch --depth=1` of that one commit it exited 0. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: no full build). After `pnpm build` (72 tasks, 71 cached) it exited 0. - **ESLint, narrowed to the 6 touched TypeScript files** (`--no-inline-config --format json`): 6 files, 0 errors and 0 warnings. Each file is matched by `eslint.config.mjs` (`--print-config`), none was ignored, and the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - **A master save can be refused for a stored detail's field rule.** Measured through the real `saveMetaItem` with a scratch test that was deleted afterwards. The stored detail `fx_detail` has one `master_detail` to `fx_master`, and a `readonlyWhen` reading `parent.acct.name`, where `acct` is a lookup on the master. Re-saving the master with only its label changed answers 422, with the issue located at `object 'fx_detail' · field 'qty' readonlyWhen`. Without the detail, the same save resolves. - Cause: the gate's differential baseline leaves out the written object's stored self, so a context finding that needs the written object present is charged to that write. The traversal refusal's `parent` holder needs the master's field types. - The property is not new to this pass. `validateObjectFieldRefs` on `main` does the same: re-saving a master is refused for a stored detail whose lookup's `lookupColumns` names a column the master lacks. - The corpus has no such detail. The detail is already refused by `os build` and faults at runtime. The changeset states the consequence and the remedy. It is reported on the card for the seat. - **Boundary.** The door judges a detail's `parent.REF.FIELD` read only when the master is in the write's context; a detail saved without its master in the package closure gets no traversal verdict there, where the build, holding the whole stack, gives one. The door stays a subset of the build. - **Out of this PR.** The nested `then` / `otherwise` predicate gap (#22042) is not addressed here; it is serial behind this pass. `formulas.mdx` could name the object save door: a docs addition, not a false line. - **Contract review.** Triage's grade asks for one per pass. It is the seat's, from an isolated subagent at the contract-review tier, and is not attached here. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a959493 commit f2a45db

7 files changed

Lines changed: 456 additions & 50 deletions
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)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032)
7+
8+
Clause-②: no (narrowing)
9+
10+
`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it.
11+
12+
The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. 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`).
17+
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories.
18+
- A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again.
19+
20+
**Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
21+
22+
**Unchanged.**
23+
24+
- 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. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row.
25+
- `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before.
26+
- Option `visibleWhen` and the object's own action predicates are still not judged at this door. `os build` judges them, and the door does not, as before.
27+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
28+
- Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships.
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 the object save door of a field-rule predicate the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose field-rule predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,8 @@ export interface AuthoringRuleContext {
309309
*
310310
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
311311
* is one entry over several PASSES, and an `object` write is admitted for its
312-
* field-formula pass and (#22032) its validation-rule pass alone
313-
* (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
312+
* field-formula pass and (#22032) its validation-rule and field-rule-slot
313+
* passes alone (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
314314
* `runtimeTypes` can say that an object write reaches the rule; it cannot say
315315
* which of the rule's passes judge that write.
316316
*/
@@ -613,6 +613,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
613613
// the pass, and 0 door errors and 0 advisories at the door's own snapshot
614614
// shape, against 2 refusals at each for the card's two bodies in the same
615615
// harness.
616+
//
617+
// [#22032, pass 2] The field-rule-slot pass joins the object door: every
618+
// field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` /
619+
// `visibleWhen`, with the `parent` gate, the `requiredWhen` null guard and
620+
// the reference-traversal refusal — the same sentence of `formulas.mdx`,
621+
// and the gap the card measured (a bare `requiredWhen: 'amount > 1'` saved
622+
// with a 200). The per-option `visibleWhen` stays fenced. No entry-level
623+
// change. MEASURED first, at both the raw and the parsed shape: every
624+
// field-rule slot the repository ships — 9 slots on 8 fields of 3 objects
625+
// (examples: app-showcase 8 slots on 2 objects, three of them
626+
// `parent`-scoped; platform: plugin-security 1 on `sys_permission_set`),
627+
// over 118 objects → 0 build
628+
// errors and 0 warnings for the pass, and 0 door errors and 0 advisories
629+
// at the door's own snapshot shape, against a refusal at each for the
630+
// card's body in the same harness.
616631
surfaces: CLI_AND_RUNTIME,
617632
runtimeTypes: ['flow', 'action', 'hook', 'object'],
618633
run: (stack, ctx) =>
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22032, pass 2 — the OBJECT write door runs the build's field-rule-slot
5+
* pass.
6+
*
7+
* ## The state this closes
8+
*
9+
* `validateStackExpressions` is the build's expression rule. Its field walk
10+
* judges every field's `requiredWhen` / `readonlyWhen` / `conditionalRequired`
11+
* / `visibleWhen` as a `record`-scoped predicate, with the root verdict, the
12+
* `parent` gate (a slot reading `parent` on an object that does not declare
13+
* exactly one `master_detail`), the #4811 null-guard gate over `requiredWhen`,
14+
* and the #20078 refusal of a `requiredWhen` / `readonlyWhen` read through a
15+
* reference field. #22019 put that rule on the object door for its
16+
* field-formula pass alone and fenced the rest off by name, so a bare
17+
* `requiredWhen: 'amount > 1'` — refused by `os build` — published clean, and
18+
* the write path then refused every write whose requirement it could not
19+
* evaluate (ADR-0137 D2).
20+
*
21+
* ## The crossing
22+
*
23+
* No registry change: the entry already declares `object`. The fence in
24+
* `runStackExpressionPasses` admits the field-rule slots on an object write,
25+
* at the build's own position in the field walk, so the door's finding IS the
26+
* build's finding — rule, location, message and hint. The per-option
27+
* `visibleWhen` (pass 3) and the object's own action predicates (pass 4) stay
28+
* fenced; that pin is in `runtime-gate.object-formula-writes.test.ts`.
29+
*
30+
* The protocol-level half — the same verdict through the real `saveMetaItem`,
31+
* `publishMetaItem` and `publishPackageDrafts` — is the #22032 pass 2 block of
32+
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
33+
*/
34+
import { describe, expect, it } from 'vitest';
35+
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
36+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
37+
38+
/**
39+
* The probe object; `slots` lands on its `name` field. `sharingModel` keeps
40+
* `security-owd-unset` quiet, so a refusal is the rule's.
41+
*/
42+
const fxField = (slots: Record<string, unknown>) => ({
43+
name: 'fx_field',
44+
label: 'Field Rule Probe',
45+
sharingModel: 'private',
46+
fields: {
47+
name: { type: 'text', label: 'Name', ...slots },
48+
amount: { type: 'number', label: 'Amount' },
49+
status: {
50+
type: 'select',
51+
label: 'Status',
52+
options: [{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }],
53+
},
54+
account: { type: 'lookup', label: 'Account', reference: 'fx_account' },
55+
},
56+
});
57+
58+
/**
59+
* One refused body per slot and per gate of the pass. Each `subject` is the
60+
* named subject of the build's finding (what the author typed), not its prose.
61+
*/
62+
const REFUSED = [
63+
// The card's body: a bare field reference.
64+
{ slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' },
65+
{ slot: 'readonlyWhen', slots: { readonlyWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' },
66+
{ slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' },
67+
{ slot: 'conditionalRequired', slots: { conditionalRequired: 'amount > 1' }, subject: 'bare reference `amount`' },
68+
// The root verdict: a root a field-level rule never binds.
69+
{ slot: 'requiredWhen', slots: { requiredWhen: 'current_user.id != null' }, subject: 'reads `current_user`' },
70+
// The `parent` gate: `fx_field` declares no `master_detail`.
71+
{ slot: 'readonlyWhen', slots: { readonlyWhen: "parent.status == 'paid'" }, subject: 'reads `parent`' },
72+
// The null-guard gate: `amount` is nullable, and the binding is total.
73+
{ slot: 'requiredWhen', slots: { requiredWhen: 'record.amount > 100' }, subject: '`record.amount`' },
74+
// The traversal refusal: a field-level predicate never reads the related record.
75+
{ slot: 'requiredWhen', slots: { requiredWhen: "record.account.name == 'x'" }, subject: 'through `record.account`' },
76+
] as const;
77+
78+
/** Valid predicates on all four declared slots of the field walk. */
79+
const VALID = {
80+
requiredWhen: 'record.amount != null && record.amount > 100',
81+
readonlyWhen: "record.status == 'closed'",
82+
visibleWhen: "record.status == 'open'",
83+
};
84+
85+
/** A detail of `fx_field`: exactly one `master_detail`, so `parent` binds. */
86+
const fxLine = () => ({
87+
name: 'fx_line',
88+
label: 'Line Probe',
89+
sharingModel: 'private',
90+
fields: {
91+
header: { type: 'master_detail', label: 'Header', reference: 'fx_field' },
92+
qty: {
93+
type: 'number',
94+
label: 'Quantity',
95+
readonlyWhen: "parent.status == 'closed'",
96+
requiredWhen: "parent.status == 'open'",
97+
},
98+
},
99+
});
100+
101+
const gateObject = (item: unknown, objects: unknown[] = []) =>
102+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects } });
103+
104+
const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
105+
fs.filter((f) => f.rule === EXPRESSION_INVALID);
106+
107+
const buildFindings = (...objects: unknown[]) => {
108+
const stack = { objects };
109+
return expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack }));
110+
};
111+
112+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
113+
114+
describe('#22032 pass 2 — the object door gives the build\'s field-rule-slot verdict', () => {
115+
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
116+
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
117+
});
118+
119+
for (const { slot, slots, subject } of REFUSED) {
120+
it(`⭐ LIT — \`${slot}: ${Object.values(slots)[0]}\` is REFUSED, located at the slot the author edits`, () => {
121+
const result = gateObject(fxField(slots));
122+
123+
expect(result.rulesRun).toContain('validateStackExpressions');
124+
const errs = expressionFindings(result.errors);
125+
const where = `object 'fx_field' · field 'name' ${slot}`;
126+
expect(errs, dump(result)).toHaveLength(1);
127+
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
128+
expect(errs[0]!.message).toContain(subject);
129+
});
130+
}
131+
132+
it('⭐ CONTROL — valid predicates on every slot publish clean, and so does a `parent`-scoped detail', () => {
133+
const result = gateObject(fxField(VALID));
134+
135+
expect(result.rulesRun).toContain('validateStackExpressions');
136+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
137+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
138+
// The detail reads `parent` with its one master stored beside it.
139+
const line = gateObject(fxLine(), [fxField(VALID)]);
140+
expect(expressionFindings(line.errors), dump(line)).toEqual([]);
141+
expect(expressionFindings(line.advisories), dump(line)).toEqual([]);
142+
// And the build agrees: the control is clean at both doors, not only this one.
143+
expect(buildFindings(fxField(VALID), fxLine())).toEqual([]);
144+
});
145+
146+
it('⭐ PARITY — for each refused body the door findings ARE the build findings', () => {
147+
for (const { slots } of REFUSED) {
148+
const body = fxField(slots);
149+
const atBuild = buildFindings(body);
150+
const atDoor = expressionFindings(gateObject(body).errors);
151+
152+
// Non-vacuous: the build refuses each of them.
153+
expect(atBuild.length, dump(slots)).toBeGreaterThan(0);
154+
expect(atDoor, dump(slots)).toEqual(atBuild);
155+
}
156+
});
157+
158+
it('a stored sibling\'s broken field rules are not this write\'s to answer for (the differential)', () => {
159+
const sibling = {
160+
...fxField({}),
161+
name: 'fx_sibling',
162+
fields: {
163+
...fxField({}).fields,
164+
...Object.fromEntries(REFUSED.map(({ slots }, i) => [`f${i}`, { type: 'text', label: `F${i}`, ...slots }])),
165+
},
166+
};
167+
// Non-vacuous: the sibling is refused at the build.
168+
expect(buildFindings(sibling).length).toBeGreaterThan(0);
169+
170+
const result = gateObject(fxField(VALID), [sibling]);
171+
172+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
173+
});
174+
});

0 commit comments

Comments
 (0)