Skip to content

Commit 8fc50b7

Browse files
fix(lint)!: a conditional validation rule's nested then / otherwise predicate meets the build's expression verdict, at os build and the object save door (#22042) (#22127)
Fixes #22042 Clause-②: no (narrowing) A `conditional` validation rule's nested `then` / `otherwise` predicates now meet the same `validateExpression` verdict as the rule's own `condition` / `when`. That holds in `os build` (`validateStackExpressions`), and so at the object save door, which has given the build's verdict for validation predicates since #22032 pass 1 (PR #22041). ## What changes - **`packages/lint/src/validate-expressions.ts`**, the validation-rule loop. - `rulePredicates` now tags each predicate with its `slot` (`condition` or `when`) and its `depth` (0 is the rule itself). Its labels are unchanged. - The two top-level `check()` calls stay as they were, with the same location and flags. The loop adds one `check()` for each nested yield (depth 1 and below) and skips depth 0, so no predicate is judged twice. - A nested finding is located at the label `rulePredicates` already builds, which is the location the null-guard gate already gives that predicate: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`. A nested `when` appends `when-predicate`. The top-level findings keep `object 'OBJECT' · validation 'NAME'`. - `traversalHydration` follows the evaluator per slot, as at the top level. It is on for a nested `condition` and off for a nested `when` (H2 below). - The new findings are emitted after the two top-level calls and before the null-guard loop. So the order of every existing finding is unchanged. - **Tests:** 7 lint-level pins (build and door) and 6 pins through the real `saveMetaItem` / `publishMetaItem`. - **Changeset:** `.changeset/22042-nested-validation-predicate-verdict.md` sets `@objectstack/lint` and `@objectstack/metadata-protocol` to `minor`. It carries `fix(lint)!`, `Clause-②: no (narrowing)`, a BREAKING section, the remedy, and the ADR-0087 disposition `not-required (no-migration-prescription)`. Nothing else moves. There is no registry change in `authoring-rules.ts` and no change in `runtime-gate.ts`. ## Measured before the change (the dispatch's H1–H6), at base `54ace18c6` - **H1, confirmed with positions re-read on this base.** - `rulePredicates` is at `:489` and recurses into `then` / `otherwise` at `:507`. - The top-level calls are `check(where, rule.condition, …, true)` at `:1891` and ``check(`${where} when`, …)`` at `:1896`. - The `rulePredicates` loop is at `:1899` and fed `checkNullGuards` alone. - At head `36ea1e8f8` these are `:499`, `:1904`, `:1909`, and `:1924` (the new nested `check()` loop) / `:1930` (the null-guard loop). - **H2, hydration per nested slot: confirmed by code read plus an existing pin.** - ObjectQL's `checkConditional` (`rule-validator.ts:4226`) evaluates its `when` against `ctx.merged`, with no `resolveTraversalScope`. It then hands the chosen branch to `evaluateRule(branch, ctx)` (`:3469`). - `evaluateRule` sends a `script` / `cross_field` branch to `checkPredicate(rule, ctx.merged, …, ctx.related, ctx.fields)`. That is the same call, with the same `related` binding, as a top-level rule. - `collectPredicateRelationships` (`:530`) is what preloads `related`, and it recurses into a `conditional`'s `then` / `otherwise` (its `visit`). The `objectql` pin `reaches a predicate nested inside a conditional` (`rule-relationship-traversal.test.ts:86`) covers that. - So a nested `condition` is hydrated, and the traversal checks are ON there. A nested `when` is evaluated by `checkConditional` without hydration, so they are OFF, as at the top-level `when` site. - **H3, location.** The rulePredicates label is used (triage: "with the label it already builds"). The door's 422 and `runAuthoringRules('build', …)` give it identically: `rule`, `where`, `path`, `message` and `hint` are compared key by key in the protocol `(d)` pin. - **H4, no double report.** Pinned: a top-level `condition` gives exactly one finding, at `validation 'NAME'` only. A faulting top-level `when` beside a faulting nested `then` gives exactly two findings, one at each location. - **H5, no registry or `runtime-gate.ts` change was needed.** `runtimeAuthoringRulesFor('object')` already lists `validateStackExpressions` (pinned), and the loop is not fenced on an object write. The protocol pins below reach the door through the built `@objectstack/lint` `dist/` with no other edit. - **H6, corpus first. The stop condition was not met.** - The corpus is every `*.object.ts` under `packages/**` and `examples/**`, plus the two `app-multi-package` sub-stacks: 111 files, 18 groups, 118 objects. - It carries 21 validation rules, of which 1 is `conditional`. One rule carries nested predicates: `examples/app-showcase` `showcase_account.churn_reason_consistency`, with 2 nested `condition`s. - The new check was run on the base build by lifting each nested predicate to the top level: 0 errors, 0 warnings. - At head, the nested locations gave 0 errors and 0 warnings at the build (raw and `ObjectSchema.parse`d shapes, and through `runAuthoringRules('build')`). The object door gave 0 errors and 0 advisories. - Positive control, the card's body: 0 build and 0 door errors at base, 2 build and 2 door errors at head. - A repo-wide `git grep` for a `conditional` rule outside tests finds only that showcase rule and spec / skill doc examples. Those carry no object, so they are not a stored corpus. The sibling `objectui` checkout at `9990f9e` has none. ## Tests (all at head `36ea1e8f8`) - `@objectstack/lint` `pnpm test`: `Test Files 123 passed (123)`, `Tests 5685 passed (5685)`. - `typecheck` exit 0, including `check:test-typecheck` (`2 file(s) / 6 error(s) … held in test-typecheck-debt.json`, unchanged). - `@objectstack/metadata-protocol` `pnpm test`: `Test Files 221 passed | 3 skipped (224)`, `Tests 28245 passed | 19 skipped (28264)`. `typecheck` exit 0. - `tsc --listFilesOnly` puts each touched test file inside its program: lint's `tsconfig.test.json` and metadata-protocol's `tsconfig.json`. - Consumers, against a rebuilt `@objectstack/cli...` and `@objectstack/objectql...` closure (59 tasks, 11 cached): - `@objectstack/objectql` `save-meta-response-conformance`, `publish-meta-response-conformance`, `publish-package-drafts-response-conformance` and `engine-predicate-relationship`: 4 files, 80 tests passed. - `@objectstack/cli` `authoring-rule-command-parity`, `validate-field-predicate-traversal` and `verify-author-time-stage`: 3 files, 16 passed. - The three cli `*.e2e.test.ts` files that read `expression-invalid` ran under `OS_TEST_TIERS=nightly`: 3 files, 34 passed. - **New pins.** - In `packages/lint/src/runtime-gate.object-validation-writes.test.ts`: - the fixtures are spec-valid (`ObjectSchema.safeParse` green); - LIT: build, `then` `sqrt(record.amount) > 1` and `otherwise` `amont > 1`; - LIT: the door equals the build; - LIT: two levels, a nested `conditional`'s `when` plus a rule below it; - CONTROL: valid nested predicates; - judged ONCE; - the traversal checks per slot; - the differential. - In `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: - (a) three active-save refusals, each a 422 `INVALID_METADATA` whose `code`, `status`, `path` and `where` name the nested rule; - (a) the draft promotion; - (b) a valid nested rule saves `active`; - (d) door equals build, key by key. ## Ablation (one-off, from committed head `b658c1a52`, with trap restore) - **The fix.** `scripts/ablation-replace.mjs` turned `if (p.depth === 0) continue;` into `const ablation22042 = true; if (ablation22042 || p.depth === 0) continue;`. - The anchor went from 1 to 0, and the blob went from `c9e828b47cee` to `63a185602ce9`. - Lint was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - Lint door file: 5 failed and 11 passed. Red were the 3 LIT pins, "judged ONCE" and "traversal per slot". Green were the fixtures, CONTROL, the differential and the 9 earlier pins. - Protocol file (dist-mediated): 5 failed and 87 passed. Red were the 3 (a) saves, the promotion and (d). Green was (b). - Restore: the blob equals HEAD `c9e828b47cee`, and `git diff HEAD` is empty. After a rebuild, `preflight --absent` found the marker absent from all 14 built files, with a clean tree. Back to green: 16/16 and 92/92. - **The hydration flag, both directions**, on lint's source-run suite. `p.slot === 'condition'` was set to `true` and then to `false`. Each time exactly 1 test failed and 15 passed: "the traversal checks follow the evaluator per slot". Each restore was proven by blob equality. ## Gates (at head `36ea1e8f8`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. Each ran with its exit code captured before any pipe, and all 63 ended at exit 0. - One needed a re-run: `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: some packages had no `dist/`). After `turbo run build` (72 tasks, 71 cached) it exited 0. - `--ran` reconciliation: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. - Changeset gates: `check-adr-0087-registration --base origin/main` exit 0 (`not-required (no-migration-prescription)` accepted), `check-changeset-no-major --base origin/main` exit 0, and `check-empty-changeset --base origin/main` exit 0. - ESLint, narrowed and measured. `eslint --no-inline-config --format json` over the 3 touched TS files gave 3 files, 0 errors and 0 warnings, with none ignored. `--print-config` matches each file, and the config enables no type-aware linting (`parserOptions.project` and `projectService` unset). So this diff cannot move an untouched file's verdict. - CI runs the full farm and is not awaited here. ## Acceptance notes - **Observation, not filed.** ObjectQL's `collectPredicateRelationships` stops descending past depth 8 (`depth > 8`). So a nested `condition` nine or more levels down is not hydrated, while lint opts it into the traversal checks at every depth. - The refusals agree at any depth: `checkPredicate` judges the conflict shapes (`resolveTraversalScope` step 1) before, and independently of, hydration. - Only the evaluation of a valid one-hop read below depth 8 differs, at a nesting depth no stored metadata here approaches. - Carrier: none. - `content/docs/data-modeling/formulas.mdx` "Build-time validation" could say that the object save door gives the build's verdict, now including nested validation predicates. This is a docs addition carried over from #22032's passes, not a false line. Carrier: none. - The contract review at `CONTRACT_REVIEW_TIER` is the seat's. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8d06a9 commit 8fc50b7

4 files changed

Lines changed: 392 additions & 9 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042)
7+
8+
Clause-②: no (narrowing)
9+
10+
A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors.
11+
12+
The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories.
17+
- An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`).
18+
- The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop.
19+
20+
**Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object 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, and that save is judged.
25+
- A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged.
26+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
27+
- Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door.
28+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes.
29+
30+
<!-- adr-0087: not-required (no-migration-prescription) a refusal, at os build and at the object save door, of a nested conditional validation predicate the published validator already refuses one level up: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose nested 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 verdict (not already-registered); and the change is a validator verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

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

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,21 @@
3030
* The protocol-level half — the same verdict through the real `saveMetaItem`
3131
* and `publishMetaItem` — is the #22032 block of
3232
* `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
33+
*
34+
* ## #22042 — one level down
35+
*
36+
* A `conditional` rule's `then` / `otherwise` is a rule the evaluator runs,
37+
* yet only the null-guard gate reached its predicates: the same unregistered
38+
* function or bare field the top level refuses published clean one level
39+
* down, in `os build` and at this door alike. The pass now runs the same
40+
* `check()` on every nested predicate, at the location the null-guard gate
41+
* already gives it (`validation rule 'outer' then → 'inner'`), with the
42+
* relationship-traversal checks on a nested `condition` (ObjectQL hydrates it)
43+
* and not on a nested `when` (it does not). The second describe block below
44+
* pins it; its protocol half is the #22042 block of the same protocol file.
3345
*/
3446
import { describe, expect, it } from 'vitest';
47+
import { ObjectSchema } from '@objectstack/spec/data';
3548
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
3649
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
3750

@@ -173,3 +186,148 @@ describe('#22032 pass 1 — the object door gives the build\'s validation-rule v
173186
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
174187
});
175188
});
189+
190+
/** #22042 — the card's two bodies, one level down: an unregistered function in `then`, a bare field in `otherwise`. */
191+
const NESTED_REFUSED = {
192+
type: 'conditional',
193+
name: 'outer',
194+
when: "record.status == 'open'",
195+
message: 'x',
196+
then: { type: 'script', name: 'inner', condition: 'sqrt(record.amount) > 1', message: 'y' },
197+
otherwise: { type: 'script', name: 'other', condition: 'amont > 1', message: 'z' },
198+
};
199+
const THEN_WHERE = "object 'fx_rule' · validation rule 'outer' then → 'inner'";
200+
const OTHERWISE_WHERE = "object 'fx_rule' · validation rule 'outer' otherwise → 'other'";
201+
202+
/** Two levels: a bare field in a nested `conditional`'s own `when`, an unregistered function one level below it. */
203+
const TWO_LEVEL = {
204+
type: 'conditional',
205+
name: 'outer',
206+
when: "record.status == 'open'",
207+
message: 'x',
208+
then: {
209+
type: 'conditional',
210+
name: 'mid',
211+
when: 'amount > 1',
212+
message: 'y',
213+
then: { type: 'script', name: 'deep', condition: 'sqrt(record.amount) > 1', message: 'z' },
214+
},
215+
};
216+
217+
/** Valid, guarded predicates in both branches and two levels down. */
218+
const NESTED_VALID = {
219+
type: 'conditional',
220+
name: 'outer',
221+
when: "record.status == 'open'",
222+
message: 'x',
223+
then: {
224+
type: 'conditional',
225+
name: 'mid',
226+
when: 'record.amount != null',
227+
message: 'y',
228+
// Guarded in its own source: the null-guard gate does not credit the enclosing `when`.
229+
then: { type: 'script', name: 'deep', condition: 'record.amount != null && record.amount > 100', message: 'z' },
230+
},
231+
otherwise: { type: 'script', name: 'other', condition: 'record.amount != null && record.amount < 0', message: 'w' },
232+
};
233+
234+
/** A probe object with a reference field, for the per-slot traversal checks. */
235+
const fxRef = (validations: unknown[]) => {
236+
const base = fxRule(validations);
237+
return { ...base, fields: { ...base.fields, account: { type: 'lookup', label: 'Account', reference: 'fx_rule' } } };
238+
};
239+
/** Reads more than one relationship hop — a shape `checkPredicate` refuses, and `checkConditional` never judges. */
240+
const MULTI_HOP = 'record.account.owner.email != null';
241+
const HYDRATION = {
242+
type: 'conditional',
243+
name: 'outer',
244+
when: "record.status == 'open'",
245+
message: 'x',
246+
then: { type: 'script', name: 'inner', condition: MULTI_HOP, message: 'y' },
247+
otherwise: {
248+
type: 'conditional',
249+
name: 'mid',
250+
when: MULTI_HOP,
251+
message: 'z',
252+
then: { type: 'script', name: 'deep', condition: 'record.amount != null', message: 'w' },
253+
},
254+
};
255+
256+
describe('#22042 — a `conditional` rule\'s nested predicates meet the same verdict, at the build and at the door', () => {
257+
it('the fixtures are spec-valid: each refusal below is the expression verdict, not the schema\'s', () => {
258+
for (const body of [fxRule([NESTED_REFUSED]), fxRule([TWO_LEVEL]), fxRule([NESTED_VALID]), fxRef([HYDRATION])]) {
259+
const parsed = ObjectSchema.safeParse(body);
260+
expect(parsed.success, dump(parsed.error?.issues)).toBe(true);
261+
}
262+
});
263+
264+
it('⭐ LIT — an unregistered function in `then` and a bare field in `otherwise` are REFUSED by `os build`, located at the nested rule', () => {
265+
const atBuild = buildFindings(fxRule([NESTED_REFUSED]));
266+
267+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([THEN_WHERE, OTHERWISE_WHERE]);
268+
for (const f of atBuild) expect(f).toMatchObject({ severity: 'error', path: f.where });
269+
expect(atBuild[0]!.message).toContain('`sqrt` is not a callable name here');
270+
expect(atBuild[1]!.message).toContain('bare reference `amont`');
271+
});
272+
273+
it('⭐ LIT — the object door REFUSES the same body, and its findings ARE the build\'s', () => {
274+
const result = gateObject(fxRule([NESTED_REFUSED]));
275+
276+
expect(result.rulesRun).toContain('validateStackExpressions');
277+
const atDoor = expressionFindings(result.errors);
278+
expect(atDoor.map((f) => f.where), dump(result)).toEqual([THEN_WHERE, OTHERWISE_WHERE]);
279+
expect(atDoor).toEqual(buildFindings(fxRule([NESTED_REFUSED])));
280+
});
281+
282+
it('⭐ LIT — two levels down: a nested `conditional`\'s `when` and the rule below it are judged', () => {
283+
const atBuild = buildFindings(fxRule([TWO_LEVEL]));
284+
285+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual([
286+
"object 'fx_rule' · validation rule 'outer' then → 'mid' when-predicate",
287+
"object 'fx_rule' · validation rule 'outer' then → 'mid' then → 'deep'",
288+
]);
289+
expect(atBuild[0]!.message).toContain('bare reference `amount`');
290+
expect(atBuild[1]!.message).toContain('`sqrt` is not a callable name here');
291+
expect(expressionFindings(gateObject(fxRule([TWO_LEVEL])).errors)).toEqual(atBuild);
292+
});
293+
294+
it('⭐ CONTROL — valid nested predicates publish clean, two levels down and in `otherwise`', () => {
295+
const result = gateObject(fxRule([NESTED_VALID]));
296+
297+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
298+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
299+
expect(buildFindings(fxRule([NESTED_VALID]))).toEqual([]);
300+
});
301+
302+
it('each predicate is judged ONCE: the rule\'s own `condition` / `when` keep their location and are not re-judged as nested', () => {
303+
// A top-level `condition` — one finding, at the rule's own location only.
304+
const top = buildFindings(fxRule([UNREGISTERED]));
305+
expect(top.map((f) => f.where), dump(top)).toEqual(["object 'fx_rule' · validation 'amount_root'"]);
306+
// A faulting top-level `when` beside a faulting nested `then` — one finding each.
307+
const both = buildFindings(fxRule([{ ...WHEN, then: NESTED_REFUSED.then }]));
308+
expect(both.map((f) => f.where), dump(both)).toEqual([
309+
"object 'fx_rule' · validation 'gate' when",
310+
"object 'fx_rule' · validation rule 'gate' then → 'inner'",
311+
]);
312+
});
313+
314+
it('the traversal checks follow the evaluator per slot: ON for a nested `condition`, OFF for a nested `when`', () => {
315+
const atBuild = buildFindings(fxRef([HYDRATION]));
316+
317+
// `checkPredicate` refuses a read deeper than one hop at any depth, so the build says so…
318+
expect(atBuild.map((f) => f.where), dump(atBuild)).toEqual(["object 'fx_rule' · validation rule 'outer' then → 'inner'"]);
319+
expect(atBuild[0]!.message).toContain('ONE hop');
320+
// …and `checkConditional` never hydrates a `when`, so the same source there earns no
321+
// traversal prescription — exactly as the top-level `when` site is opted out.
322+
const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }]));
323+
expect(topWhen, dump(topWhen)).toEqual([]);
324+
expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild);
325+
});
326+
327+
it('a stored sibling\'s nested fault is not this write\'s to answer for (the differential)', () => {
328+
const sibling = { ...fxRule([NESTED_REFUSED, TWO_LEVEL]), name: 'fx_sibling' };
329+
const result = runRuntimeAuthoringRules({ type: 'object', item: fxRule([NESTED_VALID]), context: { objects: [sibling] } });
330+
331+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
332+
});
333+
});

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

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,23 @@ function celSourceOf(raw: unknown): string | undefined {
481481
return undefined;
482482
}
483483

484+
/** One predicate a validation rule carries — see {@link rulePredicates}. */
485+
interface RulePredicate {
486+
label: string;
487+
raw: unknown;
488+
/** `condition` (a `script` / `cross_field` rule's) or `when` (a `conditional` rule's). */
489+
slot: 'condition' | 'when';
490+
/** 0 for the rule itself; 1 inside its `then` / `otherwise`, and so on down. */
491+
depth: number;
492+
}
493+
484494
/**
485495
* Every predicate a validation rule carries, including the ones nested inside a
486496
* `conditional` rule's `then` / `otherwise` — the trap hides there just as
487497
* happily as at the top level.
488498
*/
489-
function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw: unknown }> {
490-
const out: Array<{ label: string; raw: unknown }> = [];
499+
function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] {
500+
const out: RulePredicate[] = [];
491501
const name = typeof rule.name === 'string' ? rule.name : '?';
492502
const here = path ? `${path} → '${name}'` : `'${name}'`;
493503
// `condition` is the declared predicate key on every validation-rule variant
@@ -499,12 +509,14 @@ function rulePredicates(rule: AnyRec, path: string): Array<{ label: string; raw:
499509
// author who wrote both `condition` and a rejected alias had their canonical
500510
// predicate short-circuited away and the alias validated instead (#5017).
501511
const main = rule.condition;
502-
if (main != null) out.push({ label: `validation rule ${here}`, raw: main });
503-
if (rule.when != null) out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when });
512+
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, slot: 'condition', depth });
513+
if (rule.when != null) {
514+
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, slot: 'when', depth });
515+
}
504516
for (const branch of ['then', 'otherwise'] as const) {
505517
const nested = rule[branch];
506518
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
507-
out.push(...rulePredicates(nested as AnyRec, `${here} ${branch}`));
519+
out.push(...rulePredicates(nested as AnyRec, `${here} ${branch}`, depth + 1));
508520
}
509521
}
510522
return out;
@@ -1885,18 +1897,37 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
18851897
// The declared predicate key is `condition` (see `rulePredicates`).
18861898
// Validation predicates are `record`-scoped — no field flattening — so
18871899
// bare refs are flagged (#1928).
1888-
// [#18682] The two sites where a relationship traversal is SERVED: these
1889-
// are the `script` / `cross_field` conditions ObjectQL's `checkPredicate`
1890-
// hydrates. `traversalHydration` is passed here and NOWHERE else.
1900+
// [#18682] The sites where a relationship traversal is SERVED: these are
1901+
// the `script` / `cross_field` conditions ObjectQL's `checkPredicate`
1902+
// hydrates. `traversalHydration` is passed here and on a nested
1903+
// `condition` below (#22042), and NOWHERE else.
18911904
check(where, rule.condition, objectName, 'record', undefined, true);
18921905
// `conditional` rules carry a nested `when` predicate (record-scoped).
18931906
// ⚠️ `when` is evaluated by `checkConditional` WITHOUT hydration today, so
18941907
// it is opted OUT: a traversal there faults, and the conflict checks'
18951908
// prescription would not repair it.
18961909
check(`${where} when`, (rule as AnyRec).when, objectName, 'record');
1910+
const predicates = rulePredicates(rule, '');
1911+
// [#22042] The same verdict one level down, and every level below it: a
1912+
// `conditional` rule's `then` / `otherwise` is a rule the evaluator runs
1913+
// (`checkConditional` hands the branch to `evaluateRule`), so its
1914+
// predicates meet `check()` exactly as the two calls above do — located
1915+
// at the label `rulePredicates` builds, the location the null-guard gate
1916+
// below already gives the same predicate. Depth 0 is skipped: the rule's
1917+
// own `condition` / `when` met `check()` above, under their own location.
1918+
// Hydration follows the evaluator per slot, as at the top level: a
1919+
// nested `condition` is a `script` / `cross_field` rule's, which
1920+
// ObjectQL's `collectPredicateRelationships` reaches inside a
1921+
// `conditional` and `checkPredicate` hydrates; a nested `when` is a
1922+
// nested `conditional`'s, which `checkConditional` evaluates WITHOUT
1923+
// hydration, so it is opted out like the top-level `when`.
1924+
for (const p of predicates) {
1925+
if (p.depth === 0) continue;
1926+
check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, p.slot === 'condition');
1927+
}
18971928
// #4763 — null-guard gate over every predicate the rule carries, nested
18981929
// `then`/`otherwise` branches included.
1899-
for (const p of rulePredicates(rule, '')) {
1930+
for (const p of predicates) {
19001931
checkNullGuards(`object '${objectName}' · ${p.label}`, p.label, p.raw, objectName);
19011932
}
19021933
}

0 commit comments

Comments
 (0)