Skip to content

Commit 8cfe1f9

Browse files
fix(lint): os validate, os build and the object save door refuse a validations[] predicate that reads parent (#24763)
Closes #24720 Clause-②: no ## What changes A `validations[]` rule's predicates are evaluated by ObjectQL in `ruleScope(record, previous)` (`packages/objectql/src/validation/rule-validator.ts`, `checkPredicate` and `checkConditional`). That scope binds `record` and `previous`, and no master-detail header. The header is bound as `parent` only for a field's own `readonlyWhen` / `requiredWhen`, on an object with exactly one `master_detail`. A rule that read `parent` (for example `parent.status == 'paid'`) passed `os validate` silently, because `parent` is a `SCOPE_ROOTS` member and the bare-reference check stays silent on it. At runtime every write the rule judged was refused as unevaluable (`Unknown variable: parent`), the seed's rows at boot included. - **A root verdict on the rule's own predicates, at `warning` in 18.x.** `validationRuleParentIssue` in `packages/lint/src/validate-expressions.ts` reports a predicate that reads `parent`. It covers a `script` / `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same two slots on every rule nested in its `then` / `otherwise`. "Reads `parent`" is decided from the parsed AST (`readsParentRoot`, the same reader the field-rule verdict uses), so `record.parent_code` and the string `'parent'` are not judged. - **Location and prescription.** The finding is located where `check()` locates the same predicate (`object 'NAME' · validation 'RULE'`, its `when`, or the nested rule's label). The message names the slot, the rule and the root, and points at the field-rule slots where `parent` IS bound (`readonlyWhen` / `requiredWhen`), with the denormalise-onto-the-detail remedy for any other check. Its rule id is the existing `expression-invalid`. - **Both doors, neither refuses.** `os validate` / `os build` and the object save door run the same pass (`runStackExpressionPasses`). A warning fails no command, and the door admits the write and lists the finding among its `advisories`. - **The refusal is deferred to 19.0, per the standing post-GA ruling 「甲」.** A narrowing that no ADR-0087 conversion can carry ships only its loud half in 18.x, and no conversion can rewrite a predicate that reads `parent`. The same finding at `error` is planned for 19.0. The docblock and the message say so, and the ⛔ withdrawal note stays: the verdict goes in the same change that binds `parent` for a `validations[]` rule. - **The runtime is unchanged.** ## Evidence All readings at the PR head on top of `origin/main` `a7199ef974`. - **New suite** `packages/lint/src/validate-expressions.validation-rule-parent.test.ts`: 12 tests pass. It asserts `warning` on the `script`, `cross_field` and `conditional` (`when` and nested `then`) slots, and on objects with 0, 1 and 2 masters. For `os validate` and `os build` it asserts one `warning` finding and no `error` finding. At the object door it asserts that `errors` is empty, that the finding is in `advisories`, and that the door finding equals the build finding. Controls: `parent` on a field's own `readonlyWhen` / `requiredWhen` gives no finding at the build or at the door, and `record` / `previous` reads, `record.parent_code` and the literal `'parent'` stay clean. - **Ablations** through `scripts/ablation-replace.mjs`; each landed on disk and was restored to blob == HEAD with `git diff HEAD` empty. Making the verdict return `null` turned 9 of 12 tests red, and the 3 CONTROL tests stayed green. Setting the severity back to `error` turned 7 of 12 red: the warning assertions bite. - **Public door, measured.** On the stock showcase, with a temporary rule `{ type: 'script', name: 'probe_paid_lock', events: ['insert'], condition: "parent.status == 'paid'" }` on `showcase_invoice_line`, `pnpm --filter @objectstack/example-showcase validate` exits 0 (`Validation passed`). It prints the finding as a warning: `rule: expression-invalid at object 'showcase_invoice_line' · validation 'probe_paid_lock'`. The probe was applied and restored by `ablation-replace.mjs`. - **Whole package:** `pnpm --filter @objectstack/lint test` passed 151 files and 7000 tests. `pnpm --filter @objectstack/lint typecheck` passed. - **Changeset gates:** `check-adr-0087-registration` reports "this PR adds no declared-breaking changeset". `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests` and `check:spec-changes` are green. - **Corpus:** `git grep` over `examples/**` and the package objects finds `parent.` only in field `readonlyWhen` slots and in no `validations[]` rule. ## Acceptance notes - The verdict judges a rule as written, the way the rest of the expression rule does. So the warning also covers a rule with `active: false`, a rule on `events` a stack never fires, and a `conditional` branch whose `when` is never true. The 19.0 refusal will cover them too, which is why it waits for a major. - The changeset is a `patch` with `Clause-②: no`: a warning narrows nothing. The earlier breaking-`minor` declaration was withdrawn under 「甲」. - The object door is pinned in-package through `runRuntimeAuthoringRules({ type: 'object' })`. There is no `saveMetaItem` pin in `packages/metadata-protocol`, because that package is outside this card's file surface. - No rule registry or docs row is needed: the verdict uses the existing `expression-invalid` rule, and `content/docs/data-modeling/fields.mdx` already states that `parent` is bound for the field-rule slots only. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 3d8960c commit 8cfe1f9

3 files changed

Lines changed: 315 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/lint": patch
3+
---
4+
5+
fix(lint): `os validate`, `os build` and the object save door warn on a `validations[]` rule whose predicate reads `parent` (#24720)
6+
7+
Clause-②: no
8+
9+
A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, passed `os validate` silently, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included.
10+
11+
The build's expression rule (`validateStackExpressions`) now reports such a predicate at `warning`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass and lists the same finding among its advisories: the same rule id (`expression-invalid`), location and message.
12+
13+
**Nothing new is refused in 18.x.** A warning fails neither `os build` nor `os validate`, and a publish-mode object save that carries such a rule is still admitted. No ADR-0087 conversion can rewrite a predicate that reads `parent`, so under the post-GA rule for such narrowings only this warning ships in 18.x. The refusal, the same finding at `error`, is planned for 19.0. It will also cover a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true, because the verdict judges the rule as written.
14+
15+
**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail.
16+
17+
**Unchanged.**
18+
19+
- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this verdict is withdrawn in that same change.
20+
- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, as before, and so is a `record` column spelled like the root (`record.parent_code`).
21+
- No example or package object in this repository authors `parent` in a `validations[]` rule.
22+
- No public export or signature moves.

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

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,10 @@ interface RulePredicate {
552552
raw: unknown;
553553
/** 0 for the rule itself; 1 inside its `then` / `otherwise`, and so on down. */
554554
depth: number;
555+
/** The key the predicate is written under on its own rule. */
556+
slot: 'condition' | 'when';
557+
/** The name of the rule that carries it (the nested rule's own, below depth 0). */
558+
ruleName: string;
555559
}
556560

557561
/**
@@ -572,9 +576,9 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[]
572576
// author who wrote both `condition` and a rejected alias had their canonical
573577
// predicate short-circuited away and the alias validated instead (#5017).
574578
const main = rule.condition;
575-
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth });
579+
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth, slot: 'condition', ruleName: name });
576580
if (rule.when != null) {
577-
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth });
581+
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth, slot: 'when', ruleName: name });
578582
}
579583
for (const branch of ['then', 'otherwise'] as const) {
580584
const nested = rule[branch];
@@ -1710,6 +1714,73 @@ function optionVisibleWhenUserMemberIssue(
17101714
};
17111715
}
17121716

1717+
/**
1718+
* [#24720] The root verdict for a `validations[]` rule's own predicates — a
1719+
* `script` / `cross_field` rule's `condition`, a `conditional` rule's `when`,
1720+
* and the same two slots on every rule nested in its `then` / `otherwise`: a
1721+
* predicate that reads `parent` is reported, at `warning` (18.x).
1722+
*
1723+
* ## Why it is reported
1724+
*
1725+
* ObjectQL evaluates every one of those predicates in `ruleScope(record,
1726+
* previous)` (`rule-validator.ts`: `checkPredicate`, `checkConditional`), which
1727+
* binds `record` and `previous` and no master-detail header. The header is
1728+
* bound as `parent` for a field's own `readonlyWhen` / `requiredWhen` only
1729+
* ({@link FIELD_RULE_BOUND_ROOTS}), on an object with exactly one
1730+
* `master_detail`. So the predicate faults (`Unknown variable: parent`) on
1731+
* every write it judges, and an unevaluable rule fails closed: the write is
1732+
* refused, the seed's own rows at boot included. Nothing else named it,
1733+
* because `parent` is in `SCOPE_ROOTS`, which the strict env declares, so the
1734+
* bare-reference check stays silent. The field-rule slots one level up accept
1735+
* the same text, which is what makes it the shape an author copies.
1736+
*
1737+
* ## Why a warning, and when it becomes a refusal
1738+
*
1739+
* A rule the runtime cannot evaluate must not pass a door (ADR-0049), so the
1740+
* end state is `error`. But refusing it narrows what `os build` and the object
1741+
* save door accept (a rule marked `active: false`, or a `conditional` branch
1742+
* whose `when` is never true, builds and writes today), and no ADR-0087
1743+
* conversion can rewrite a predicate that reads `parent`. Under the standing
1744+
* post-GA ruling (甲), a narrowing no conversion carries ships only its loud
1745+
* half in 18.x: this verdict reports at `warning`, which neither fails
1746+
* `os build` nor refuses a save (the door lists it among its advisories). The
1747+
* refusal — the same finding at `error` — is planned for 19.0.
1748+
*
1749+
* ## Why `parent` alone
1750+
*
1751+
* This is the one root the record's own field rules bind and its validation
1752+
* rules do not: the credible copy, the one the runtime's own diagnostics name.
1753+
* Whether the other platform-wide roots the rule evaluator binds no more of
1754+
* (the acting user's among them) earn the same verdict is not decided here,
1755+
* because neither the runtime nor its callers were measured for them.
1756+
*
1757+
* ⛔ This verdict is withdrawn in the same change that binds `parent` for a
1758+
* `validations[]` rule, never before.
1759+
*
1760+
* `null` = nothing to report: the source does not parse (the syntax pass owns
1761+
* that), or it reads no `parent`.
1762+
*/
1763+
function validationRuleParentIssue(
1764+
objectName: string | undefined,
1765+
ruleName: string,
1766+
slot: 'condition' | 'when',
1767+
source: string,
1768+
): string | null {
1769+
if (!readsParentRoot(source)) return null;
1770+
const owner = objectName ? `'${objectName}'` : 'this object';
1771+
return (
1772+
`\`${slot}\` of validation rule '${ruleName}' reads \`parent\`, but the server evaluates a ` +
1773+
`\`validations[]\` rule against \`record\` and \`previous\` only, so \`parent\` is unbound there. ` +
1774+
`The rule can never be evaluated, and the server REFUSES every write it judges — a rule that ` +
1775+
`cannot be evaluated fails closed, seed rows included. This is a warning today; ` +
1776+
`\`os build\` and the object save door will refuse it from 19.0. The master-detail header is bound as ` +
1777+
`\`parent\` only for a field's own \`readonlyWhen\` and \`requiredWhen\`, on an object with exactly ` +
1778+
`one \`master_detail\`: to lock or require a field on the header's state, write the predicate on ` +
1779+
`that field's \`readonlyWhen\` or \`requiredWhen\` instead. For any other check, read a column ` +
1780+
`${owner} declares (denormalise the header value you need onto ${owner}).`
1781+
);
1782+
}
1783+
17131784
/**
17141785
* [#20078] The master object of an object with exactly ONE `master_detail`
17151786
* relationship — the record the field level binds as `parent` — or `undefined`
@@ -2848,6 +2919,21 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28482919
if (p.depth === 0) continue;
28492920
check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, true);
28502921
}
2922+
// [#24720] The rule's root verdict, over every predicate it carries,
2923+
// located where `check()` above located the same predicate. `parent` is
2924+
// a `SCOPE_ROOTS` member, so `check()` never reports a bare reference to
2925+
// it and the two verdicts stay disjoint; see
2926+
// {@link validationRuleParentIssue}.
2927+
for (const p of predicates) {
2928+
const source = celSourceOf(p.raw);
2929+
const message = source ? validationRuleParentIssue(objectName, p.ruleName, p.slot, source) : null;
2930+
if (!message) continue;
2931+
const at = p.depth > 0
2932+
? `object '${objectName}' · ${p.label}`
2933+
: p.slot === 'when' ? `${where} when` : where;
2934+
// `warning` in 18.x, `error` from 19.0: see the docblock's ruling.
2935+
issues.push({ where: at, message, source: source!, severity: 'warning' });
2936+
}
28512937
// #4763 — null-guard gate over every predicate the rule carries, nested
28522938
// `then`/`otherwise` branches included.
28532939
for (const p of predicates) {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24720 — a `validations[]` rule's predicate that reads `parent` is reported
5+
* at authoring, at `os validate` / `os build` and at the object save door, at
6+
* `warning` in 18.x (the standing post-GA ruling 甲: a narrowing no ADR-0087
7+
* conversion carries ships its loud half first; the refusal comes in 19.0).
8+
*
9+
* ## The state this closes
10+
*
11+
* ObjectQL evaluates every `validations[]` predicate — a `script` /
12+
* `cross_field` rule's `condition` (`checkPredicate`), a `conditional` rule's
13+
* `when` (`checkConditional`), and the rules nested in its `then` /
14+
* `otherwise` — in `ruleScope(record, previous)`: `record` and `previous`, and
15+
* no master-detail header. The header is bound as `parent` for a field's own
16+
* `readonlyWhen` / `requiredWhen` only. `parent` is a `SCOPE_ROOTS` member, so
17+
* the bare-reference check was silent on it, and a rule reading
18+
* `parent.status` passed every door — then faulted (`Unknown variable:
19+
* parent`) on every write it judged, which the server refuses as unevaluable,
20+
* the seed's own rows included.
21+
*
22+
* ## The verdict
23+
*
24+
* One root verdict over every predicate the rule carries, located where
25+
* `check()` locates the same predicate, reporting `parent` at warning, with the
26+
* prescription that is true for it: the field-rule slots where `parent` IS
27+
* bound, or a denormalised column. The runtime is unchanged.
28+
*/
29+
import { describe, expect, it } from 'vitest';
30+
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
31+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
32+
import { validateStackExpressions } from './validate-expressions.js';
33+
34+
const PARENT = "parent.status == 'paid'";
35+
36+
const header = {
37+
name: 'fx_header',
38+
label: 'Header',
39+
sharingModel: 'private',
40+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
41+
};
42+
43+
/** A detail with exactly ONE `master_detail` — the shape on which the field-rule slots bind `parent`. */
44+
const line = (
45+
validations: unknown[],
46+
extraFields: Record<string, unknown> = {},
47+
masters: number = 1,
48+
) => ({
49+
name: 'fx_line',
50+
label: 'Line',
51+
sharingModel: 'private',
52+
fields: {
53+
...(masters >= 1 ? { hdr: { type: 'master_detail', label: 'Header', reference: 'fx_header' } } : {}),
54+
...(masters >= 2 ? { hdr2: { type: 'master_detail', label: 'Header 2', reference: 'fx_header' } } : {}),
55+
amount: { type: 'number', label: 'Amount' },
56+
parent_code: { type: 'text', label: 'Parent code' },
57+
...extraFields,
58+
},
59+
validations,
60+
});
61+
62+
const script = (condition: string, name = 'paid_lock') => ({ name, type: 'script', condition, message: 'x' });
63+
64+
const stackOf = (...objects: unknown[]) => ({ objects });
65+
66+
const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
67+
fs.filter((f) => f.rule === EXPRESSION_INVALID);
68+
69+
const commandFindings = (command: 'validate' | 'build', ...objects: unknown[]) => {
70+
const stack = stackOf(...objects);
71+
return expressionFindings(runAuthoringRules(command, { normalized: stack, parsed: stack }));
72+
};
73+
74+
const gateObject = (item: unknown, context: unknown[]) =>
75+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: context } });
76+
77+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
78+
79+
const WHERE = "object 'fx_line' · validation 'paid_lock'";
80+
81+
describe('#24720 — a `validations[]` predicate reading `parent` is reported at authoring', () => {
82+
it('⭐ LIT — a `script` rule\'s `condition` reading `parent` is reported at warning, located at the rule', () => {
83+
const issues = validateStackExpressions(stackOf(header, line([script(PARENT)])));
84+
85+
expect(issues, dump(issues)).toHaveLength(1);
86+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning', source: PARENT });
87+
expect(issues[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
88+
});
89+
90+
it('⭐ the prescription names the field-rule slots where `parent` IS bound, and the denormalise remedy', () => {
91+
const [issue] = validateStackExpressions(stackOf(header, line([script(PARENT)])));
92+
93+
expect(issue!.message).toContain('`readonlyWhen`');
94+
expect(issue!.message).toContain('`requiredWhen`');
95+
expect(issue!.message).toContain("denormalise the header value you need onto 'fx_line'");
96+
});
97+
98+
it('⭐ the `os validate` and `os build` commands both give the finding, at warning: nothing they gate on', () => {
99+
for (const command of ['validate', 'build'] as const) {
100+
const stack = stackOf(header, line([script(PARENT)]));
101+
const all = runAuthoringRules(command, { normalized: stack, parsed: stack });
102+
const found = expressionFindings(all);
103+
expect(found, `${command}: ${dump(found)}`).toHaveLength(1);
104+
expect(found[0]).toMatchObject({ severity: 'warning', where: WHERE });
105+
// The 18.x half of the ruling: no `error` anywhere, so the command does not fail.
106+
expect(all.filter((f) => f.severity === 'error'), `${command}: ${dump(all)}`).toEqual([]);
107+
}
108+
});
109+
110+
it('⭐ a `cross_field` rule\'s `condition` is judged the same way', () => {
111+
const rule = { name: 'paid_lock', type: 'cross_field', fields: ['amount'], condition: PARENT, message: 'x' };
112+
const issues = validateStackExpressions(stackOf(header, line([rule])));
113+
114+
expect(issues, dump(issues)).toHaveLength(1);
115+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'warning' });
116+
});
117+
118+
it('⭐ a `conditional` rule\'s `when`, and the rules in its `then` / `otherwise`, are judged at their own locations', () => {
119+
const rule = {
120+
type: 'conditional',
121+
name: 'gate',
122+
when: PARENT,
123+
message: 'x',
124+
then: { type: 'script', name: 'inner', condition: "parent.status != 'void'", message: 'y' },
125+
otherwise: { type: 'script', name: 'other', condition: 'record.amount != null && record.amount > 0', message: 'z' },
126+
};
127+
const issues = validateStackExpressions(stackOf(header, line([rule])));
128+
129+
expect(issues.map((i) => i.where), dump(issues)).toEqual([
130+
"object 'fx_line' · validation 'gate' when",
131+
"object 'fx_line' · validation rule 'gate' then → 'inner'",
132+
]);
133+
for (const i of issues) {
134+
expect(i.severity).toBe('warning');
135+
expect(i.message).toContain('reads `parent`');
136+
}
137+
});
138+
139+
it('gives one finding per predicate however often it reads `parent`', () => {
140+
const issues = validateStackExpressions(
141+
stackOf(header, line([script(`${PARENT} && parent.name != ''`)])),
142+
);
143+
expect(issues, dump(issues)).toHaveLength(1);
144+
});
145+
146+
it('refuses it whatever the object\'s master count: no validation rule binds `parent` on any object', () => {
147+
for (const masters of [0, 2]) {
148+
const issues = validateStackExpressions(stackOf(header, line([script(PARENT)], {}, masters)));
149+
const here = issues.filter((i) => i.where === WHERE);
150+
expect(here, `${masters} masters: ${dump(issues)}`).toHaveLength(1);
151+
expect(here[0]!.severity).toBe('warning');
152+
}
153+
});
154+
155+
it('⭐ CONTROL — `parent` on the field\'s own `readonlyWhen` / `requiredWhen` is still accepted', () => {
156+
for (const key of ['readonlyWhen', 'requiredWhen']) {
157+
const issues = validateStackExpressions(
158+
stackOf(header, line([], { note: { type: 'text', label: 'Note', [key]: PARENT } })),
159+
);
160+
expect(issues, `${key}: ${dump(issues)}`).toEqual([]);
161+
}
162+
});
163+
164+
it('⭐ CONTROL — `record` / `previous` reads, a column spelled like the root and the string `parent` pass', () => {
165+
for (const condition of [
166+
'record.amount != null && record.amount > 100',
167+
'previous != null && previous.amount != null && previous.amount > 100',
168+
"record.parent_code == 'a'",
169+
"record.parent_code == 'parent'",
170+
]) {
171+
const issues = validateStackExpressions(stackOf(header, line([script(condition)])));
172+
expect(issues, `${condition}: ${dump(issues)}`).toEqual([]);
173+
}
174+
});
175+
});
176+
177+
describe('#24720 — the object door gives the build\'s verdict', () => {
178+
it('⭐ LIT — the measured body is ADMITTED at the door with the finding as an advisory, located at the rule', () => {
179+
const result = gateObject(line([script(PARENT)]), [header]);
180+
181+
expect(result.rulesRun).toContain('validateStackExpressions');
182+
expect(result.errors, dump(result)).toEqual([]);
183+
const advs = expressionFindings(result.advisories);
184+
expect(advs, dump(result)).toHaveLength(1);
185+
expect(advs[0]).toMatchObject({ severity: 'warning', where: WHERE, path: WHERE });
186+
expect(advs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
187+
});
188+
189+
it('⭐ PARITY — the door finding IS the build finding', () => {
190+
const atBuild = commandFindings('build', header, line([script(PARENT)]));
191+
const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).advisories);
192+
193+
expect(atBuild, dump(atBuild)).toHaveLength(1);
194+
expect(atDoor, dump(atDoor)).toEqual(atBuild);
195+
});
196+
197+
it('⭐ CONTROL — `parent` on a field\'s own `readonlyWhen` publishes clean, and the build agrees', () => {
198+
const body = line([], { note: { type: 'text', label: 'Note', readonlyWhen: PARENT } });
199+
const result = gateObject(body, [header]);
200+
201+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
202+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
203+
expect(commandFindings('build', header, body)).toEqual([]);
204+
});
205+
});

0 commit comments

Comments
 (0)