Skip to content

Commit e793e9d

Browse files
fix(lint): a validation rule's expression finding carries the slot's key path (#27675)
domain:spec#3 · os-support-ai · session_01WGufzj2PNcHjESYvrviQf7 <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBSqjVuhLMsVzHs6jpRpHNTh)_ Fixes #27643 Part of #27482 (layer 1 of 2, the producer; the Studio reader, layer 2, stays on #27482). Clause-②: no Metadata-protocol: none Both declarations are the claim's, measured and kept: no accept set moves (every body that saved or published before does so now, every refusal still refuses with the same rule, `where` and message); only the locator of an existing refusal changes, and no metadata key or value is touched. They match what PR #26999 (#26858, the flow-edge precedent) declared for the same kind of change: `Clause-②: no`, `Metadata-protocol: none`, one `@objectstack/lint` patch changeset. `@objectstack/metadata-protocol` publishes nothing new here (its diff is one test file), so it has no changeset line. ## 维护者速读 - 改了什么:对象校验规则(`validations[]`)表达式被拒时,发现项的 `path` 从描述文字(`object 'X' · validation rule 'R'`)改为键路径(`objects[0].validations[1].condition`);草稿读取的 `_diagnostics[].path` 因此变成 `validations.1.condition`,发布 422 的 `issues[].path` 变成 `objects.X.validations[1].condition`。`where` 文字不变。 - 为什么改:Studio 按字段路径对号显示服务端结论;流程连线条件已经这样做(#26858),校验规则此前只有描述文字,界面对不上号,无法据此暂停自动保存(#27482 第 2 层要用)。 - 风险与回滚:拒绝与放行完全不变,只换定位字段;直接读 `path` 文字的调用方会看到新拼写(仓内已查遍,见 Acceptance notes)。回滚即还原本 PR。 - 先看:`packages/lint/src/validate-expressions.ts`(对象校验规则循环里的 `atSlot`),`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`(#22032 块新增的草稿读取断言),`packages/lint/src/validate-expressions.validation-rule-path.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.validation-rule-path.test.ts`,应 9/9 全绿。 - 需要你做的:无,席位落地(契约复核后)。 ## What changes ### Measured first, on `origin/main` `b761968d` A draft of an object whose `amount` field is nullable, with three rules (`ok_rule` clean; `amount_rule` `record.amount > 100`; a `conditional` `cond_rule` with `when: record.amount > 5` and `then` → `inner` `record.amount < 1000`), saved through the real `saveMetaItem` (draft), read with `getMetaItem({ state: 'draft' })`, then published: Before (`_diagnostics.errors[]`, then the publish 422 `issues[]` path / where): ```text { path: "object 'rule_probe' · validation rule 'amount_rule'", code: "expression-invalid" } { path: "object 'rule_probe' · validation rule 'cond_rule' when-predicate", code: "expression-invalid" } { path: "object 'rule_probe' · validation rule 'cond_rule' then → 'inner'", code: "expression-invalid" } 422 path = where = "object 'rule_probe' · validation rule 'amount_rule'" (and likewise for the other two) ``` The flow-edge precedent on the same harness (the #26707 `edge_cel` flow, edge `e2` condition `record.name ==`): ```text { path: "edges.1.condition", code: "expression-invalid" } 422 path = "flows[0].edges[1].condition", where = "flow 'edge_cel' · edge 'e2' (decide→end) condition" ``` After, at this PR's head (same probe): ```text { path: "validations.1.condition", code: "expression-invalid" } { path: "validations.2.when", code: "expression-invalid" } { path: "validations.2.then.condition", code: "expression-invalid" } 422 path = "objects.rule_probe.validations[1].condition", where = "object 'rule_probe' · validation rule 'amount_rule'" 422 path = "objects.rule_probe.validations[2].when", where = "object 'rule_probe' · validation rule 'cond_rule' when-predicate" 422 path = "objects.rule_probe.validations[2].then.condition", where = "object 'rule_probe' · validation rule 'cond_rule' then → 'inner'" ``` Messages are byte-identical before and after. ### The producer (`packages/lint/src/validate-expressions.ts`) - The object validation-rule loop computes the rule's key path over the lists as authored (`objects[I].validations[N]`, `indexOf` on the raw arrays, the #26858 spelling), and stamps `${rulePath}.${slot}` onto every issue each slot's call pushes. That covers all three verdicts the pass gives: `check()` (syntax, bare refs, unknown fields, hop checks), the `parent` root verdict (#24720) and `checkNullGuards` (#4763). - The expression slots a rule has, measured from `rulePredicates` and the loop: `condition` and `when` on the rule itself, and the same two on every `conditional` rule nested under `then` / `otherwise`, at any depth. `RulePredicate` gains `key` (`condition`, `when`, `then.condition`, `otherwise.then.when`, …). The nested slots are included: they are slots of the same top-level rule and have an unambiguous key path (`validations.0.then.condition`); a flow's nested region edge was left out in #26858 only because it has none. - A map-form `objects` collection has no index to spell, so its findings keep `where` alone, as a map-form `flows` collection's do. - No new member: `ExprIssue.path` (from #26858) carries it, and `authoring-rules.ts`'s `path: i.path ?? i.where` needs no change. The publish rule is not copied anywhere. - The runtime gate is untouched: its differential compares the raw positional paths (siblings sit at the same index in every snapshot), `nameKeyFindingPath` re-spells `objects[I]` as `objects.NAME` after it, and `withPublishVerdict`'s `itemRelativeIssuePath` strips that prefix for `_diagnostics`. ## Tests - New `packages/lint/src/validate-expressions.validation-rule-path.test.ts` (9 cases), one per slot and verdict, each asserting the key path AND the unchanged `where`: the null-guard gate on `condition`; `check()` on `condition`; `check()` and the null-guard gate on `when`; nested `then.condition`, `otherwise.when`, `otherwise.then.condition`; the `parent` root verdict; raw-list indexing past `null` members (object and rule); map-form `objects` keeps no path; a field formula finding gains none; and the publish gate's by-name spelling `objects.rule_probe.validations[1].condition`. - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: the #22032 block's pin moves from the prose to `objects.fx_rule.validations[0].condition` (the 422 on save and on promotion), with `where` still asserted as the prose; a new case pins the draft read's `_diagnostics[].path` at `validations.0.condition` for both refused bodies; `(d)` door-vs-build parity keeps `rule`/`where`/`message`/`hint` exact and compares `path` once the door's by-name top-level spelling is applied. The #22042 (nested) and #24957 (`parent`) blocks of the same file pinned the same prose path and move the same way. Nothing is skipped. - Lint suites that pinned `path: where` for a validation-rule finding move the same way: `runtime-gate.object-validation-writes.test.ts` (8 cases; a local `asAtDoor` applies the door's by-name spelling for the door-vs-build `toEqual`s), `validate-expressions.validation-rule-parent.test.ts` (2), `runtime-gate.object-field-existence-spellings.test.ts` (the `validation condition` slot's door path). - `validate-expressions.test.ts`'s #5017 receiver meta-test reads the new index through the tabled `obj` receiver (`obj.validations.indexOf`), so no excuse is added. ### Ablation (path stamping removed) At head `7363e13f` (the fix and the moved pins committed; the later changeset commit touches no code), through `scripts/ablation-replace.mjs` with a trap restore, under the lock: - Mutation: `if (!rulePath) return;` → `if (!rulePath || 'ablated-27643') return;` in `atSlot` (no finding gains a path). Landed: anchor 1 → 0, blob `a4aad9da` → `fbc41d77`. `pnpm --filter @objectstack/lint build` exit 0; `ablation-dist-preflight.mjs @objectstack/lint 'ablated-27643'`: marker present in 4 built files, exit 0. - Red: `validate-expressions.validation-rule-path.test.ts` 7 failed / 2 passed (the map-form and off-rule controls stay green, as they should); `protocol.runtime-authoring-gate.test.ts -t '22032|22042|24957'` 11 failed / 30 passed (the draft-read `_diagnostics[].path` pin among them). - Restore: blob back to `a4aad9da` = HEAD, `git diff HEAD` empty, rebuild exit 0, preflight `--absent` exit 0 (marker absent from all 20 built files, tree clean). Green again: 9/9 and 41/41. - A first attempt used a comment marker (`/* ablated-27643 */`), which the bundler strips: the preflight answered "marker ABSENT from dist/" (exit 1), so that run's reading is void even though the same suites went red; it was redone with the string marker above. ## Acceptance notes Readers that match on a validation-rule finding's path, grepped repo-wide at this head (`git grep` for `· validation '`, `· validation rule '` and `.path` beside them): - `packages/lint` tests: the four files above, updated. Every other lint test matches on `where`, which is unchanged. - `packages/metadata-protocol`: the #22032, #22042 and #24957 blocks, updated. `withPublishVerdict` needs no change (it already strips an `objects.NAME` prefix). - `packages/cli`: `os lint` prints `at ${issue.path}` (`authoringFindingToLintIssue`), so for these findings it now prints the key path where it printed the `where` text a second time; the message still leads with `where`. `artifact-packages.ts`'s `findingKey` normalises the top-level index (`objects[3]` → `objects[]`), so its per-package dedupe is unaffected. `os validate` / `os build` / `doctor` print `where`. - `packages/qa/dogfood/test/rule-hop-undeclared-related-field.dogfood.test.ts` matches `[rule, where]`, unchanged. - `ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts`: `serverErrorsAt` matches a path by equality, and no Studio field asks for `validations.N.condition` yet, so nothing changes on screen until layer 2 (#27482). `withServerOnlyErrors` dedupes a client error and a server error with the same path and message; with the prose path no client error ever matched, with the key path a client error raised at `validations.N.condition` with the same message is shown once, which is the intended direction. - `objectql`'s runtime refusal text (`validation rule 'X' on OBJ could not be evaluated`) is a different message on a different surface and is untouched. In-flight intersection (open PRs at the time of the claim, files listed per PR): only #27592 (head `e2368298`) touches `protocol.runtime-authoring-gate.test.ts`, in the #27069 related-list block near the end of the file (an added `it` and a changed fixture); no hunk overlaps the #22032 / #22042 / #24957 blocks. No open PR touches `validate-expressions.ts` or `authoring-rules.ts`. ## Verification All at head `9d9987f5` (the PR head), under `scripts/pm/os-verify-lock.sh` on the shared box: <details><summary>Suites: lint and metadata-protocol test + typecheck, all exit 0</summary> - `pnpm --filter @objectstack/lint test`: exit 0, Test Files 178 passed (178), Tests 7613 passed (7613). - `pnpm --filter @objectstack/lint typecheck`: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: exit 0, Test Files 279 passed, 4 skipped (283), Tests 21782 passed, 26 skipped (21808). - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. - Lock VERDICT command-exit 0 (5m23s). </details> <details><summary>Derived gates: 59 derived, 59 run, all exit 0</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 59 families derived at `9d9987f5`. - Serial pass: 56 exit 0 and 3 exit 3 (prerequisites, nothing measured): `check-plugin-teardown-shape --self-test` (shallow clone), `check:dual-build-cjs-loads` and `check:lean-entry-closure` (no full build). - After `git fetch origin 621a487` and a full `turbo run build --concurrency=2` (exit 0, lock VERDICT command-exit 0): all three rerun exit 0. - `dispatch-gates --ran`: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - Dogfood: not run (no `!` in the title and no `(narrowing)` arm). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzj2PNcHjESYvrviQf7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2feee83 commit e793e9d

7 files changed

Lines changed: 300 additions & 26 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a validation rule's `expression-invalid` finding carries the slot's key path (`objects[0].validations[1].condition`) beside its `where` text
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An `expression-invalid` finding on an object validation rule's predicate used to be located by its `where` prose alone (`object 'invoice' · validation rule 'amount_rule'`), which is also what its `path` carried, so a reader keyed on paths could not address it. Its `path` is now the judged slot's key path in the spelling a flow edge's findings use, so a draft's `_diagnostics[].path` reports it item-relative (`validations.1.condition`, `validations.2.when`, `validations.2.then.condition`) and the publish refusal's `issues[].path` reads `objects.invoice.validations[1].condition`.
11+
12+
- `where`, the message and the verdict are unchanged: nothing is accepted or refused differently.
13+
- Every finding of the validation-rule pass moves: the expression check, the `parent` root verdict and the null-guard gate, on a rule's `condition` and `when` and on a `conditional` rule's nested `then` / `otherwise` branches.
14+
- The object and the rule are indexed in the lists as authored (a `null` member before them does not renumber them).
15+
- An object in a map-form `objects` collection keeps `where` as its `path`, as before.
16+
- `os lint` prints the key path after `at` for these findings, where it printed the `where` text a second time.

‎packages/lint/src/runtime-gate.object-field-existence-spellings.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ const WHERE: Readonly<Record<Slot, string>> = {
6666
'validation condition': "object 'fx_probe' · validation 'probe_rule'",
6767
};
6868

69+
/**
70+
* The door finding's `path`: the `where` prose on the two field slots, and on
71+
* the validation rule (#27643) the judged slot's key path, its top-level index
72+
* re-spelled by name at the door.
73+
*/
74+
const DOOR_PATH: Readonly<Record<Slot, string>> = {
75+
...WHERE,
76+
'validation condition': 'objects.fx_probe.validations[0].condition',
77+
};
78+
6979
const SLOTS = Object.keys(WHERE) as Slot[];
7080

7181
/** Every member spelling that names `f`; the dot row is the one that was already refused. */
@@ -103,7 +113,7 @@ describe('an undeclared field is refused in every member spelling, at both doors
103113
expect(door.rulesRun).toContain('validateStackExpressions');
104114
const errs = expressionFindings(door.errors);
105115
expect(errs, dump(door)).toHaveLength(1);
106-
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE[slot], path: WHERE[slot] });
116+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE[slot], path: DOOR_PATH[slot] });
107117
expect(errs[0]!.message).toContain('unknown field `zz_typo`');
108118
});
109119
}

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ const buildFindings = (body: unknown) => {
111111

112112
const dump = (r: unknown) => JSON.stringify(r, null, 2);
113113

114+
/**
115+
* [#27643] The build's findings as the door spells them: a validation rule's
116+
* finding carries the judged slot's key path, and the door re-spells its
117+
* top-level index by name (`objects[0]` → `objects.fx_rule`); every other key
118+
* is the build's own.
119+
*/
120+
const asAtDoor = <T extends { path: string }>(fs: readonly T[]): T[] =>
121+
fs.map((f) => ({ ...f, path: f.path.replace(/^objects\[0\]/, 'objects.fx_rule') }));
122+
114123
describe('#22032 pass 1 — the object door gives the build\'s validation-rule verdict', () => {
115124
it('needs no registry change: `validateStackExpressions` is already on the object door', () => {
116125
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
@@ -123,7 +132,7 @@ describe('#22032 pass 1 — the object door gives the build\'s validation-rule v
123132
const errs = expressionFindings(result.errors);
124133
const where = "object 'fx_rule' · validation 'amount_root'";
125134
expect(errs, dump(result)).toHaveLength(1);
126-
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
135+
expect(errs[0]).toMatchObject({ severity: 'error', where, path: 'objects.fx_rule.validations[0].condition' });
127136
expect(errs[0]!.message).toContain('`sqrt` is not a callable name here');
128137
});
129138

@@ -171,7 +180,7 @@ describe('#22032 pass 1 — the object door gives the build\'s validation-rule v
171180

172181
// Non-vacuous: the build refuses each of them.
173182
expect(atBuild.length, dump(rule)).toBeGreaterThan(0);
174-
expect(atDoor, dump(rule)).toEqual(atBuild);
183+
expect(atDoor, dump(rule)).toEqual(asAtDoor(atBuild));
175184
}
176185
});
177186

@@ -265,7 +274,11 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
265274
const atBuild = buildFindings(fxRule([NESTED_REFUSED]));
266275

267276
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 });
277+
expect(atBuild.map((f) => f.path), dump(atBuild)).toEqual([
278+
'objects[0].validations[0].then.condition',
279+
'objects[0].validations[0].otherwise.condition',
280+
]);
281+
for (const f of atBuild) expect(f).toMatchObject({ severity: 'error' });
269282
expect(atBuild[0]!.message).toContain('`sqrt` is not a callable name here');
270283
expect(atBuild[1]!.message).toContain('bare reference `amont`');
271284
});
@@ -276,7 +289,7 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
276289
expect(result.rulesRun).toContain('validateStackExpressions');
277290
const atDoor = expressionFindings(result.errors);
278291
expect(atDoor.map((f) => f.where), dump(result)).toEqual([THEN_WHERE, OTHERWISE_WHERE]);
279-
expect(atDoor).toEqual(buildFindings(fxRule([NESTED_REFUSED])));
292+
expect(atDoor).toEqual(asAtDoor(buildFindings(fxRule([NESTED_REFUSED]))));
280293
});
281294

282295
it('⭐ LIT — two levels down: a nested `conditional`\'s `when` and the rule below it are judged', () => {
@@ -288,7 +301,11 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
288301
]);
289302
expect(atBuild[0]!.message).toContain('bare reference `amount`');
290303
expect(atBuild[1]!.message).toContain('`sqrt` is not a callable name here');
291-
expect(expressionFindings(gateObject(fxRule([TWO_LEVEL])).errors)).toEqual(atBuild);
304+
expect(atBuild.map((f) => f.path), dump(atBuild)).toEqual([
305+
'objects[0].validations[0].then.when',
306+
'objects[0].validations[0].then.then.condition',
307+
]);
308+
expect(expressionFindings(gateObject(fxRule([TWO_LEVEL])).errors)).toEqual(asAtDoor(atBuild));
292309
});
293310

294311
it('⭐ CONTROL — valid nested predicates publish clean, two levels down and in `otherwise`', () => {
@@ -326,7 +343,7 @@ describe('#22042 — a `conditional` rule\'s nested predicates meet the same ver
326343
const topWhen = buildFindings(fxRef([{ ...HYDRATION, when: MULTI_HOP, then: NESTED_VALID.otherwise, otherwise: undefined }]));
327344
expect(topWhen.map((f) => f.where), dump(topWhen)).toEqual(["object 'fx_rule' · validation 'outer' when"]);
328345
expect(topWhen[0]!.message).toContain('ONE hop');
329-
expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(atBuild);
346+
expect(expressionFindings(gateObject(fxRef([HYDRATION])).errors)).toEqual(asAtDoor(atBuild));
330347
});
331348

332349
it('a stored sibling\'s nested fault is not this write\'s to answer for (the differential)', () => {
@@ -364,7 +381,8 @@ describe('#24468 — the object door refuses a rule hop to a field the target do
364381
const errs = expressionFindings(result.errors);
365382
expect(errs, dump(result)).toHaveLength(1);
366383
const where = "object 'fx_order' · validation 'north_only'";
367-
expect(errs[0]).toMatchObject({ severity: 'error', where, path: where });
384+
// [#27643] The second rule's `condition`, by key path; `where` stays the prose.
385+
expect(errs[0]).toMatchObject({ severity: 'error', where, path: 'objects.fx_order.validations[1].condition' });
368386
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
369387
expect(errs[0]!.message).toContain('the rule has no verdict');
370388
});
@@ -428,7 +446,7 @@ describe('#24512 — the object door judges a `conditional` rule\'s `when` hop a
428446
const errs = expressionFindings(result.errors);
429447
expect(errs, dump(result)).toHaveLength(1);
430448
const where = "object 'fx_order' · validation 'gate' when";
431-
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where, path: where });
449+
expect(errs[0]).toMatchObject({ rule: EXPRESSION_INVALID, severity: 'error', where, path: 'objects.fx_order.validations[0].when' });
432450
expect(errs[0]!.message).toContain('unknown field `region` on `fx_account`');
433451
expect(errs[0]!.message).toContain('the rule has no verdict');
434452
});

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ export interface ExprIssue {
165165
* [#26858] The judged slot's key path in the stack, in the spelling the other
166166
* rules' findings carry (`flows[0].edges[1].condition`), beside the human
167167
* `where`. Set only where the slot has one: a top-level flow edge's
168-
* `condition`. Absent, the finding is located by `where` alone.
168+
* `condition`, and (#27643) an object validation rule's `condition` / `when`
169+
* (`objects[0].validations[1].condition`), a nested branch's included
170+
* (`objects[0].validations[1].then.condition`). Absent, the finding is
171+
* located by `where` alone.
169172
*/
170173
path?: string;
171174
}
@@ -573,14 +576,19 @@ interface RulePredicate {
573576
slot: 'condition' | 'when';
574577
/** The name of the rule that carries it (the nested rule's own, below depth 0). */
575578
ruleName: string;
579+
/**
580+
* [#27643] Its key path inside the top-level rule (`condition`, `when`,
581+
* `then.condition`, `otherwise.then.when`), for a finding's `path`.
582+
*/
583+
key: string;
576584
}
577585

578586
/**
579587
* Every predicate a validation rule carries, including the ones nested inside a
580588
* `conditional` rule's `then` / `otherwise` — the trap hides there just as
581589
* happily as at the top level.
582590
*/
583-
function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] {
591+
function rulePredicates(rule: AnyRec, path: string, depth = 0, keyPrefix = ''): RulePredicate[] {
584592
const out: RulePredicate[] = [];
585593
const name = typeof rule.name === 'string' ? rule.name : '?';
586594
const here = path ? `${path} → '${name}'` : `'${name}'`;
@@ -593,14 +601,18 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[]
593601
// author who wrote both `condition` and a rejected alias had their canonical
594602
// predicate short-circuited away and the alias validated instead (#5017).
595603
const main = rule.condition;
596-
if (main != null) out.push({ label: `validation rule ${here}`, raw: main, depth, slot: 'condition', ruleName: name });
604+
if (main != null) {
605+
out.push({ label: `validation rule ${here}`, raw: main, depth, slot: 'condition', ruleName: name, key: `${keyPrefix}condition` });
606+
}
597607
if (rule.when != null) {
598-
out.push({ label: `validation rule ${here} when-predicate`, raw: rule.when, depth, slot: 'when', ruleName: name });
608+
out.push({
609+
label: `validation rule ${here} when-predicate`, raw: rule.when, depth, slot: 'when', ruleName: name, key: `${keyPrefix}when`,
610+
});
599611
}
600612
for (const branch of ['then', 'otherwise'] as const) {
601613
const nested = rule[branch];
602614
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
603-
out.push(...rulePredicates(nested as AnyRec, `${here} ${branch}`, depth + 1));
615+
out.push(...rulePredicates(nested as AnyRec, `${here} ${branch}`, depth + 1, `${keyPrefix}${branch}.`));
604616
}
605617
}
606618
return out;
@@ -2955,6 +2967,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29552967
// ── Object validation-rule + formula predicates ────────────────────
29562968
for (const obj of objects) {
29572969
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
2970+
// [#27643] The object's index in the authored list, for a validation-rule
2971+
// finding's key path (`recordsOf` hands an array's own members back). A
2972+
// map-form `objects` has no index to spell, so its findings keep `where`
2973+
// alone, as a map-form `flows` collection's do (#26858).
2974+
const objectIndex = Array.isArray(stack.objects) ? stack.objects.indexOf(obj) : -1;
29582975
// `validations` is the key `ObjectSchema` declares; `validationRules` is a
29592976
// rejected alias of it (#5017) — see the `## Scope` table above.
29602977
const validations = obj.validations;
@@ -2963,6 +2980,18 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29632980
// position, so the door's findings and their order are the build's.
29642981
for (const rule of recordsOf(validations)) {
29652982
const where = `object '${objectName}' · validation '${(rule.name as string) ?? '?'}'`;
2983+
// [#27643] Every finding below also carries the judged slot's key path
2984+
// (`objects[0].validations[1].condition`), indexed over the lists the
2985+
// author wrote, in the spelling a flow edge's findings use (#26858), so
2986+
// a draft's `_diagnostics` reports it item-relative
2987+
// (`validations.1.condition`). `where` stays the human locator.
2988+
const ruleIndex = objectIndex >= 0 && Array.isArray(obj.validations) ? obj.validations.indexOf(rule) : -1;
2989+
const rulePath = ruleIndex >= 0 ? `objects[${objectIndex}].validations[${ruleIndex}]` : undefined;
2990+
const atSlot = (key: string, first: number): void => {
2991+
if (!rulePath) return;
2992+
const path = `${rulePath}.${key}`;
2993+
for (let k = first; k < issues.length; k++) issues[k] = { ...issues[k]!, path };
2994+
};
29662995
// The declared predicate key is `condition` (see `rulePredicates`).
29672996
// Validation predicates are `record`-scoped — no field flattening — so
29682997
// bare refs are flagged (#1928).
@@ -2973,8 +3002,12 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29733002
// `traversalHydration` is passed on those two slots here and nested below
29743003
// (#22042), and on a formula field's `expression` (`judgeFieldFormula`,
29753004
// #24197), and NOWHERE else.
3005+
let first = issues.length;
29763006
check(where, rule.condition, objectName, 'record', undefined, true);
3007+
atSlot('condition', first);
3008+
first = issues.length;
29773009
check(`${where} when`, (rule as AnyRec).when, objectName, 'record', undefined, true);
3010+
atSlot('when', first);
29783011
const predicates = rulePredicates(rule, '');
29793012
// [#22042] The same verdict one level down, and every level below it: a
29803013
// `conditional` rule's `then` / `otherwise` is a rule the evaluator runs
@@ -2989,7 +3022,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29893022
// meet the traversal checks.
29903023
for (const p of predicates) {
29913024
if (p.depth === 0) continue;
3025+
first = issues.length;
29923026
check(`object '${objectName}' · ${p.label}`, p.raw, objectName, 'record', undefined, true);
3027+
atSlot(p.key, first);
29933028
}
29943029
// [#24720] The rule's root verdict, over every predicate it carries,
29953030
// located where `check()` above located the same predicate. `parent` is
@@ -3004,12 +3039,16 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
30043039
? `object '${objectName}' · ${p.label}`
30053040
: p.slot === 'when' ? `${where} when` : where;
30063041
// `error`: a rule the runtime cannot evaluate must not pass a door; see the docblock.
3042+
first = issues.length;
30073043
issues.push({ where: at, message, source: source!, severity: 'error' });
3044+
atSlot(p.key, first);
30083045
}
30093046
// #4763 — null-guard gate over every predicate the rule carries, nested
30103047
// `then`/`otherwise` branches included.
30113048
for (const p of predicates) {
3049+
first = issues.length;
30123050
checkNullGuards(`object '${objectName}' · ${p.label}`, p.label, p.raw, objectName);
3051+
atSlot(p.key, first);
30133052
}
30143053
}
30153054
// Field-level formulas (computed fields) reference the same object.

‎packages/lint/src/validate-expressions.validation-rule-parent.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ describe('#24720 — the object door gives the build\'s verdict', () => {
196196
expect(result.rulesRun).toContain('validateStackExpressions');
197197
const errs = expressionFindings(result.errors);
198198
expect(errs, dump(result)).toHaveLength(1);
199-
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
199+
// [#27643] Located at the judged slot's key path; `where` stays the prose.
200+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: 'objects.fx_line.validations[0].condition' });
200201
expect(errs[0]!.message).toContain("validation rule 'paid_lock' reads `parent`");
201202
// No longer an advisory: the finding moved, it was not duplicated.
202203
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
@@ -207,7 +208,12 @@ describe('#24720 — the object door gives the build\'s verdict', () => {
207208
const atDoor = expressionFindings(gateObject(line([script(PARENT)]), [header]).errors);
208209

209210
expect(atBuild, dump(atBuild)).toHaveLength(1);
210-
expect(atDoor, dump(atDoor)).toEqual(atBuild);
211+
// [#27643] Equal once the door's by-name spelling of the top-level index
212+
// is applied to the build's positional key path (`objects[1]`, after the header).
213+
expect(atBuild[0]!.path).toBe('objects[1].validations[0].condition');
214+
expect(atDoor, dump(atDoor)).toEqual(
215+
atBuild.map((f) => ({ ...f, path: f.path.replace(/^objects\[1\]/, 'objects.fx_line') })),
216+
);
211217
});
212218

213219
it('⭐ CONTROL — `parent` on a field\'s own `readonlyWhen` publishes clean, and the build agrees', () => {

0 commit comments

Comments
 (0)