Skip to content

Commit 3efccfd

Browse files
fix(lint): a flow node config-value finding carries the slot's key path, so Studio's Problems row selects the node (#28192)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao5fJZsb8XwP8keiSa2558sv)_ Fixes #27828 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:流程节点配置值上的 lint 发现(例如「更新记录」里留空的表达式)现在除了原来的文字位置说明,还带上指向该节点配置值的键路径(如 `flows[0].nodes[1].config.fields.status`,草稿诊断里读作 `nodes.1.config.fields.status`)。 - 为什么改:之前这类发现只有一句文字说明,Studio 的「问题」面板能列出它,却点不到、也标不到对应节点;流程一大,作者很难找到挡住发布的那个节点。连线的同类问题 #26858 已经修过,这次补上节点这一半。 - 风险与回滚:只是给发现多加一个 `path` 字段,拒收与否、提示文字都不变;CLI 的 `rule: … at …` 一行会改印键路径(连线自 #26858 起已是如此)。回滚即 revert 本 PR。 - 先看:`packages/lint/src/validate-expressions.ts` 节点遍历末尾的路径标记段,`packages/lint/src/validate-expressions.node-config-path.test.ts`,`packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts` 新增的 `[#27828]` 用例。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.node-config-path.test.ts`,预期 7 条全过,其中 “every node-config arm locates its finding at the slot it judged” 覆盖六类节点发现。 - 需要你做的:无,席位落地。 ## What changes - **Producer** (`packages/lint/src/validate-expressions.ts`, the flow node walk in `runStackExpressionPasses`). Each node site that pushes a finding now also records, in a per-node `where` → key map, the key it judged: `config.condition`, `config.KEY` (the spec's node-config judge), `BLOCK.SLOT_PATH` for a ledger slot and a value-template refusal (so a `connector_action` input from #27968 reads `connectorConfig.input.KEY`), `config.TEXT_SLOT`, and for a `script` node's callable check `config.function` (or the first retired dispatch key the node still carries). At the end of the node's walk, every finding that node produced gets `path: flows[F].nodes[N].KEY`, the #26858 edge spelling. - **Same limits as the edge path.** `N` indexes the authored `flow.nodes` list (a `null` member before the node does not renumber it). A node inside a nested region (`loop` body) and a map-form `flows` collection have no index to spell, so their findings keep `where` alone. `where` and `message` are unchanged everywhere. - **No consumer change.** `authoring-rules.ts` already sets `path: i.path ?? i.where` (only its comment gains the node case), and `metadata-protocol` `itemRelativeIssuePath` already re-spells `flows[0].nodes[1].config.fields.status` as `nodes.1.config.fields.status`, which `ui` `flow-problems.ts` `serverTarget` maps onto the node. `ui/` is untouched. - **`ExprIssue.path` docblock** names the node case. - **Changeset** `27828-flow-node-config-finding-path.md`: `@objectstack/lint` patch, both declaration lines. ## Tests - New `packages/lint/src/validate-expressions.node-config-path.test.ts` (7 tests): the Update Records blank-expression finding carries `flows[0].nodes[1].config.fields.status` with `where` unchanged; a `null` member before the node and a second flow before this one index correctly; one fixture exercises six arms (node `condition`, value-template token, text-slot token, undeclared config key, script with no `function`, `connector_action` input) and asserts each node's path set; map-form `flows` and a node inside a `loop` body keep `path` undefined; the runtime authoring gate's refusal is located at the node config value. - `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts` gains `[#27828] a node config value refused by expression-invalid is located at the node, not in where prose`: a draft with a blank Update Records value is stored, and its draft read's `_diagnostics` reports `valid: false` with every `expression-invalid` entry at `nodes.1.config.fields.name`. Before the fix it read `flow 'blank_value' · node 'set' (update_record) update_record field value at config.fields.name`. - `validate-expressions.edge-condition-path.test.ts`: the case "a finding off the edge condition gains no path" asserted that a node `condition` finding has no path. That is the behaviour this card changes, so it now asserts the node's own path (`flows[0].nodes[0].config.condition`), still no edge path. - `validate-expressions.test.ts` receiver meta-test (#5017): the new local `nodeSlotKeys` is excused with its reason (its keys are `Map` methods, never metadata keys). <details><summary>Red before the fix, and the ablation</summary> - Before the producer edit (tests committed first on `origin/main` `9f13ee81`): the new lint file ran 5 failed | 2 passed (the two `undefined`-path cases passed), and the metadata-protocol case failed with the prose path quoted above. - Ablation on committed head `e8b5683d` with `scripts/ablation-replace.mjs`: the stamp line (`if (key !== undefined) issues[k] = { ...issues[k]!, path: ... }`) replaced by `void key;` (anchor 0 left, replacement 1, counted on disk), lint rebuilt (`OS_SKIP_DTS=1`), `ablation-dist-preflight.mjs @objectstack/lint 'nodes[${nodeIndex}]' --absent` exit 0. Lint: 6 failed | 7 passed across the node and edge path files. metadata-protocol (reads lint's `dist/`): the `[#27828]` case failed, 17 passed. Restored by the EXIT trap; `git diff HEAD` empty and the blob hash matched `HEAD`; lint rebuilt, preflight `'void key;' --absent` exit 0, metadata-protocol 18/18. </details> <details><summary>Local runs</summary> - `pnpm --filter @objectstack/lint exec vitest run`: 180 files; 7655 passed and 1 failed on the first run (the #5017 receiver meta-test, fixed in the next commit), then `validate-expressions.test.ts` 440/440. - `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.draft-publish-verdict.test.ts`: 18/18. - `pnpm --filter @objectstack/lint typecheck` (with `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. </details> ## Acceptance notes - **CLI text face.** `authoringFindingDetailLines` prints `rule: expression-invalid at PATH`. For these findings that line now names the key path instead of repeating the prose; the verdict line above it still carries `where`. The edge findings have printed this way since #26858. - **Runtime gate differential.** The gate's baseline/candidate fingerprint includes `path`. Positional node paths compare across its passes the same way edge and validation-rule paths already do; no gate change was needed. - **Not given a path.** Findings from other node-level rules outside `validateStackExpressions` are unchanged (several, such as `flow-api-trigger-secret-missing`, already carry a key path). A nested-region node's finding could be located at its container (`nodes.CONTAINER.config.body.nodes.I…`); this PR follows the #26858 edge precedent and leaves it on `where`. - **Optional ui pin** (card Acceptance, separate lane): not in this PR. ## Verification Head `6360f96e` (merged with `origin/main` `3cc45cc4`, which carries #28150). The gate sweep, the suites and the ablation ran on this tree. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 59 commands. All 59 ran with stdin from /dev/null, exit codes captured before any pipe: 56 exited 0 and 3 first exited 3 (PREREQUISITE NOT MET): `check-plugin-teardown-shape --self-test` (shallow clone; pinned commit `621a4876` fetched), `check:dual-build-cjs-loads` and `check:lean-entry-closure` (unbuilt packages). After the prerequisite, all 3 exited 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED". Ran list: `spec-seat-1/r2026-10-30/out/27828-gates-ran-6360f96e.list` in the seat's folder. - Full dogfood: not owed (no `!` in the title, no `(narrowing)` arm). - `test:repo` was not run locally (CI's Test Core). CI on this PR: not yet read; the seat reads the check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxhpYpVUxgK5jbQMXT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fd87b7 commit 3efccfd

7 files changed

Lines changed: 229 additions & 6 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a flow node config-value finding carries the slot's key path, so Studio's Problems row selects the node
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`validateStackExpressions` located a finding on a flow node's config value (an `expression-invalid` on an Update Records `fields` value, a node `condition`, a text slot, an undeclared config key, a `script` node with no `function`) by its `where` prose alone. The runtime authoring gate then used that prose as the finding's `path`, so a draft's `_diagnostics` entry for it could not be resolved to a node: Studio's Problems list showed the refusal but could not select or badge the node.
11+
12+
Each such finding now also carries the slot's key path, in the spelling a flow edge's `condition` finding uses: `flows[0].nodes[1].config.fields.status` (a draft's `_diagnostics` reads `nodes.1.config.fields.status`). A `connector_action` input value is located in its own block (`flows[0].nodes[2].connectorConfig.input.customerId`). The `where` prose and the message are unchanged. As for edges, a node inside a nested region (a `loop` body) and a map-form `flows` collection have no index to spell, so their findings keep `where` alone. The CLI's `rule: … at …` line prints the key path for these findings, as it does for an edge's.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
793793
rule: EXPRESSION_INVALID,
794794
where: i.where,
795795
// [#26858] The slot's key path where the pass spells one (a flow edge's
796-
// `condition`), so a draft's `_diagnostics` can address the field.
796+
// `condition`, #27828 a flow node's config value), so a draft's
797+
// `_diagnostics` can address the field.
797798
path: i.path ?? i.where,
798799
// [#22161] The authored source is a QUOTE of what the author wrote, not
799800
// a fix, so it rides the verdict — `— source: \`…\`` is the spelling

‎packages/lint/src/validate-expressions.edge-condition-path.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('expression-invalid on a flow edge condition carries a key path (#26858
7575
for (const i of onEdge) expect(i.path).toBeUndefined();
7676
});
7777

78-
it('a finding off the edge condition gains no path', () => {
78+
it('a finding off the edge condition gains no edge path (#27828: a node condition carries its node\'s)', () => {
7979
const issues = validateStackExpressions({
8080
flows: [
8181
flow([{ id: 'e1', source: 'start', target: 'end' }], {
@@ -87,7 +87,7 @@ describe('expression-invalid on a flow edge condition carries a key path (#26858
8787
],
8888
});
8989
expect(issues.length).toBeGreaterThan(0);
90-
for (const i of issues) expect(i.path).toBeUndefined();
90+
for (const i of issues) expect(i.path).toBe('flows[0].nodes[0].config.condition');
9191
});
9292

9393
it('reaches the publish gate: the refusal is located at the edge condition, message unchanged', () => {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #27828 — a flow node's config-value finding carries the slot's key path.
5+
*
6+
* `expression-invalid` is what publish refuses a node's config value with
7+
* (measured on a blank expression in an Update Records value), and its finding
8+
* was located by its `where` prose alone (`flow 'f' · node 'set' (update_record)
9+
* update_record field value at config.fields.status`). A draft's
10+
* `_diagnostics` path was therefore that prose, so Studio's Problems row listed
11+
* the refusal but could not select the node. The finding now carries the key
12+
* path in the spelling the edge findings use since #26858
13+
* (`flows[0].nodes[1].config.fields.status`) beside the unchanged `where` and
14+
* message.
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
19+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
20+
import { validateStackExpressions } from './validate-expressions.js';
21+
import type { ExprIssue } from './validate-expressions.js';
22+
23+
type AnyRec = Record<string, unknown>;
24+
25+
const BLANK = { dialect: 'cel', source: ' ' };
26+
27+
const START = { id: 'start', type: 'start', label: 'Start' };
28+
const END = { id: 'end', type: 'end', label: 'End' };
29+
30+
/** An Update Records node whose `status` value is a blank CEL envelope. */
31+
const SET = {
32+
id: 'set',
33+
type: 'update_record',
34+
label: 'Set',
35+
config: { objectName: 'task', filter: { id: 'x' }, fields: { title: 'kept', status: BLANK } },
36+
};
37+
38+
function flow(nodes: unknown[], extra: AnyRec = {}): AnyRec {
39+
return {
40+
name: 'node_cfg',
41+
label: 'Node config',
42+
type: 'autolaunched',
43+
nodes,
44+
edges: [
45+
{ id: 'e1', source: 'start', target: 'set' },
46+
{ id: 'e2', source: 'set', target: 'end' },
47+
],
48+
...extra,
49+
};
50+
}
51+
52+
const onNode = (issues: ExprIssue[], id: string) => issues.filter((i) => i.where.includes(`node '${id}'`));
53+
54+
describe('a flow node config-value finding carries a key path (#27828)', () => {
55+
it('names the node by its index in the authored list, where and message unchanged', () => {
56+
const issues = onNode(validateStackExpressions({ flows: [flow([START, SET, END])] }), 'set');
57+
expect(issues.length).toBeGreaterThan(0);
58+
for (const i of issues) {
59+
expect(i.path).toBe('flows[0].nodes[1].config.fields.status');
60+
expect(i.where).toBe("flow 'node_cfg' · node 'set' (update_record) update_record field value at config.fields.status");
61+
}
62+
});
63+
64+
it('indexes the raw list: a null member before the node does not renumber it', () => {
65+
const issues = onNode(validateStackExpressions({ flows: [flow([START, null, SET, END])] }), 'set');
66+
expect(issues.length).toBeGreaterThan(0);
67+
for (const i of issues) expect(i.path).toBe('flows[0].nodes[2].config.fields.status');
68+
});
69+
70+
it('indexes the flow in the stack', () => {
71+
const clean = { ...flow([START, END]), name: 'clean', edges: [{ id: 'e1', source: 'start', target: 'end' }] };
72+
const issues = onNode(validateStackExpressions({ flows: [clean, flow([START, SET, END])] }), 'set');
73+
expect(issues.length).toBeGreaterThan(0);
74+
for (const i of issues) expect(i.path).toBe('flows[1].nodes[1].config.fields.status');
75+
});
76+
77+
it('every node-config arm locates its finding at the slot it judged', () => {
78+
const nodes = [
79+
{ ...START, config: { condition: 'record.name ==' } },
80+
// A value slot's retired single-brace token (the template-refusal arm).
81+
{ id: 'tok', type: 'update_record', label: 'Tok', config: { objectName: 'task', filter: { id: 'x' }, fields: { title: '{record.name}' } } },
82+
// A text slot's single-brace token (the text-slot arm).
83+
{ id: 'say', type: 'notify', label: 'Say', config: { recipients: ['a@b.c'], title: 'Hi {record.name}', message: 'ok' } },
84+
// A key the node's contract does not declare (the config-refusal arm).
85+
{ id: 'odd', type: 'notify', label: 'Odd', config: { bogusKey: 1, recipients: ['a@b.c'], title: 'ok', message: 'ok' } },
86+
// A script with no callable (the script arm).
87+
{ id: 'run', type: 'script', label: 'Run', config: {} },
88+
// A sibling-block value slot (#27968): the path names its block.
89+
{
90+
id: 'call',
91+
type: 'connector_action',
92+
label: 'Call',
93+
config: {},
94+
connectorConfig: { connectorId: 'crm', actionId: 'push', input: { customerId: '{record.id}' } },
95+
},
96+
];
97+
const issues = validateStackExpressions({ flows: [flow(nodes, { edges: [] })] });
98+
const pathsOf = (id: string) => [...new Set(onNode(issues, id).map((i) => i.path))];
99+
expect(pathsOf('start')).toEqual(['flows[0].nodes[0].config.condition']);
100+
expect(pathsOf('tok')).toEqual(['flows[0].nodes[1].config.fields.title']);
101+
expect(pathsOf('say')).toEqual(['flows[0].nodes[2].config.title']);
102+
expect(pathsOf('odd')).toEqual(['flows[0].nodes[3].config.bogusKey']);
103+
expect(pathsOf('run')).toEqual(['flows[0].nodes[4].config.function']);
104+
expect(pathsOf('call')).toEqual(['flows[0].nodes[5].connectorConfig.input.customerId']);
105+
});
106+
107+
it('a map-form flows collection has no index to spell, so the finding keeps where alone', () => {
108+
const { name: _name, ...body } = flow([START, SET, END]);
109+
const issues = onNode(validateStackExpressions({ flows: { node_cfg: body } }), 'set');
110+
expect(issues.length).toBeGreaterThan(0);
111+
for (const i of issues) expect(i.path).toBeUndefined();
112+
});
113+
114+
it('a node inside a nested region keeps where alone, as a nested edge does', () => {
115+
const loop = {
116+
id: 'each',
117+
type: 'loop',
118+
label: 'Each',
119+
config: {
120+
collection: { dialect: 'cel', source: '[1, 2]' },
121+
itemVariable: 'item',
122+
body: { nodes: [{ ...SET, id: 'inner' }], edges: [] },
123+
},
124+
};
125+
const issues = onNode(
126+
validateStackExpressions({ flows: [flow([START, loop, END], { edges: [{ id: 'e1', source: 'start', target: 'each' }, { id: 'e2', source: 'each', target: 'end' }] })] }),
127+
'inner',
128+
);
129+
expect(issues.length).toBeGreaterThan(0);
130+
for (const i of issues) expect(i.path).toBeUndefined();
131+
});
132+
133+
it('reaches the publish gate: the refusal is located at the node config value, message unchanged', () => {
134+
const result = runRuntimeAuthoringRules({ type: 'flow', item: flow([START, SET, END]) });
135+
const refusals = result.errors.filter((f) => f.rule === 'expression-invalid');
136+
expect(refusals.length).toBeGreaterThan(0);
137+
for (const f of refusals) {
138+
expect(f.path).toMatch(/^flows(\[0\]|\.node_cfg)\.nodes\[1\]\.config\.fields\.status$/);
139+
expect(f.where).toBe("flow 'node_cfg' · node 'set' (update_record) update_record field value at config.fields.status");
140+
}
141+
});
142+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3624,6 +3624,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
36243624
// its `traversals` and `multiHopFields` — the formula package's own
36253625
// result keys, never metadata keys.
36263626
'analysis',
3627+
// [#27828] A node's `where` → judged key path index, filled by the
3628+
// node walk's own sites. Its keys are `Map.prototype.set` / `get`, never
3629+
// metadata keys; the config it describes is read off the tabled `cfg`.
3630+
'nodeSlotKeys',
36273631
]);
36283632
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
36293633
});

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

Lines changed: 34 additions & 3 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`, and (#27643) an object validation rule's `condition` / `when`
168+
* `condition`, (#27828) a top-level flow node's judged config value
169+
* (`flows[0].nodes[1].config.fields.status`, a sibling block's
170+
* `flows[0].nodes[2].connectorConfig.input.customerId`), and (#27643) an
171+
* object validation rule's `condition` / `when`
169172
* (`objects[0].validations[1].condition`), a nested branch's included
170173
* (`objects[0].validations[1].then.condition`). Absent, the finding is
171174
* located by `where` alone.
@@ -2722,7 +2725,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27222725
// that guarantee stops, through the file's one home for it.
27232726
for (const node of recordsOf(graph.nodes)) {
27242727
const cfg = (node.config ?? {}) as AnyRec;
2728+
// [#27828] Each site below names the key it judged beside its `where`;
2729+
// after the walk, this node's findings carry that key path (see the
2730+
// stamp at the end of this body).
2731+
const nodeFirst = issues.length;
2732+
const nodeSlotKeys = new Map<string, string>();
27252733
const nodeCondWhere = `${at} · node '${node.id}' (${node.type}) condition`;
2734+
nodeSlotKeys.set(nodeCondWhere, 'config.condition');
27262735
if (!checkStructuralCondition(nodeCondWhere, cfg.condition).refused) {
27272736
const from = issues.length;
27282737
check(nodeCondWhere, cfg.condition, objectName);
@@ -2762,8 +2771,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27622771
|| (configRefusal.code === 'node-config-refused-by-contract' && configRefusal.path === 'functionName')
27632772
)));
27642773
for (const configRefusal of configRefusals) {
2774+
const configWhere = `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`;
2775+
nodeSlotKeys.set(configWhere, `config.${configRefusal.path}`);
27652776
issues.push({
2766-
where: `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`,
2777+
where: configWhere,
27672778
message: configRefusal.message,
27682779
source: configRefusal.source,
27692780
severity: 'error',
@@ -2774,6 +2785,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27742785
// `connectorConfig.input` — and names the block it is in.
27752786
for (const found of resolveFlowNodeExpressions(nodeType, cfg, node)) {
27762787
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at ${found.block}.${found.path}`;
2788+
nodeSlotKeys.set(slotWhere, `${found.block}.${found.path}`);
27772789
// [#15137] `value` slots are checkable too, by their own rule — see
27782790
// `checkDeclaredValue`. Not shadowing-checked: the shadow diagnostic is
27792791
// about a bare identifier resolving to a variable instead of a field,
@@ -2832,8 +2844,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28322844
// and the two legacy `assignment` shapes, so build and registration
28332845
// give one verdict, and each refusal names the token's CEL spelling.
28342846
for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg, node)) {
2847+
const templateWhere = `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at ${templateRefusal.block}.${templateRefusal.path}`;
2848+
nodeSlotKeys.set(templateWhere, `${templateRefusal.block}.${templateRefusal.path}`);
28352849
issues.push({
2836-
where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at ${templateRefusal.block}.${templateRefusal.path}`,
2850+
where: templateWhere,
28372851
message: templateRefusal.message,
28382852
source: templateRefusal.source,
28392853
severity: 'error',
@@ -2850,6 +2864,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28502864
// throw at the node mid-run.
28512865
for (const slot of flowNodeTextSlotSources(nodeType, cfg)) {
28522866
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${slot.label} at config.${slot.path}`;
2867+
nodeSlotKeys.set(slotWhere, `config.${slot.path}`);
28532868
const tokenRefusal = textSlotTemplateRefusal(slot.source);
28542869
if (tokenRefusal !== undefined) {
28552870
issues.push({ where: slotWhere, message: tokenRefusal, source: slot.source, severity: 'error' });
@@ -2879,6 +2894,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28792894
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
28802895
const retired = ['actionType', 'template', 'recipients', 'variables', 'script']
28812896
.filter((k) => cfg[k] != null);
2897+
// The first retired key the author wrote, or the `function` to write.
2898+
nodeSlotKeys.set(`${at} · node '${node.id}' (script) callable`, `config.${retired[0] ?? 'function'}`);
28822899
if (retired.length > 0) {
28832900
issues.push({
28842901
where: `${at} · node '${node.id}' (script) callable`,
@@ -2914,6 +2931,20 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
29142931
});
29152932
}
29162933
}
2934+
// [#27828] A top-level node's findings also carry the judged slot's key
2935+
// path, the #26858 edge shape (`flows[0].nodes[1].config.fields.status`),
2936+
// indexed over the list the author wrote (the coerced one drops `null`
2937+
// members and would renumber). A nested region's node keeps `where`, as
2938+
// its edges do.
2939+
const nodeIndex = flowIndex >= 0 && graph.path.length === 0 && Array.isArray(flow.nodes)
2940+
? flow.nodes.indexOf(node)
2941+
: -1;
2942+
if (nodeIndex >= 0) {
2943+
for (let k = nodeFirst; k < issues.length; k++) {
2944+
const key = nodeSlotKeys.get(issues[k]!.where);
2945+
if (key !== undefined) issues[k] = { ...issues[k]!, path: `flows[${flowIndex}].nodes[${nodeIndex}].${key}` };
2946+
}
2947+
}
29172948
}
29182949
// `recordsOf`, not `as unknown as AnyRec[]` (#16910) — the same reason as
29192950
// the `graph.nodes` reader above: a NESTED region's edge list is only

‎packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,39 @@ describe('a draft reads the verdict its publish gives (#26707)', () => {
462462
expect.objectContaining({ path: 'nodes.0.config.secret', code: 'flow-api-trigger-secret-missing' }),
463463
);
464464
});
465+
466+
it('[#27828] a node config value refused by expression-invalid is located at the node, not in where prose', async () => {
467+
const { engine } = makeStubEngine();
468+
const protocol = new ObjectStackProtocolImplementation(engine);
469+
// Studio's Update Records value with its expression left blank.
470+
const flow = {
471+
name: 'blank_value',
472+
label: 'Blank Value',
473+
type: 'autolaunched',
474+
nodes: [
475+
{ id: 'start', type: 'start', label: 'Start' },
476+
{
477+
id: 'set',
478+
type: 'update_record',
479+
label: 'Set',
480+
config: { objectName: 'deal', filter: { id: 'x' }, fields: { name: { dialect: 'cel', source: ' ' } } },
481+
},
482+
{ id: 'end', type: 'end', label: 'End' },
483+
],
484+
edges: [
485+
{ id: 'e1', source: 'start', target: 'set' },
486+
{ id: 'e2', source: 'set', target: 'end' },
487+
],
488+
};
489+
await stageDraft(protocol, 'flow', flow);
490+
491+
const diagnostics = await draftDiagnostics(protocol, 'flow', flow.name);
492+
const refused = (diagnostics.errors ?? []).filter((e) => e.code === 'expression-invalid');
493+
expect(diagnostics.valid).toBe(false);
494+
expect(refused.length).toBeGreaterThan(0);
495+
// `nodes.<index>.…` is the spelling Studio's Problems list maps onto a node.
496+
for (const e of refused) expect(e.path).toBe('nodes.1.config.fields.name');
497+
});
465498
});
466499

467500
describe('[#27444] an object draft without its package namespace prefix', () => {

0 commit comments

Comments
 (0)