Skip to content

Commit 2a4d68c

Browse files
committed
wip(lint): a flow edge condition finding carries its key path
Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 912076f commit 2a4d68c

3 files changed

Lines changed: 127 additions & 1 deletion

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
791791
severity: i.severity ?? 'error',
792792
rule: EXPRESSION_INVALID,
793793
where: i.where,
794-
path: i.where,
794+
// [#26858] The slot's key path where the pass spells one (a flow edge's
795+
// `condition`), so a draft's `_diagnostics` can address the field.
796+
path: i.path ?? i.where,
795797
// [#22161] The authored source is a QUOTE of what the author wrote, not
796798
// a fix, so it rides the verdict — `— source: \`…\`` is the spelling
797799
// the flow engine's runtime refusals already use — and reaches the CLI
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26858 — a flow edge's `condition` finding carries the slot's key path.
5+
*
6+
* `expression-invalid` is what publish refuses a flow edge's CEL with, and its
7+
* finding was located by its `where` prose alone (`flow 'f' · edge 'e2'
8+
* (decide→end) condition`). A draft's `_diagnostics` is item-relative and
9+
* dotted (`edges.1.condition`), so Studio's edge condition field could not ask
10+
* whether the refusal was its own. The finding now carries the key path in the
11+
* spelling the other rules use (`flows[0].edges[1].condition`) beside the
12+
* unchanged `where` and message.
13+
*/
14+
15+
import { describe, expect, it } from 'vitest';
16+
17+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
18+
import { validateStackExpressions } from './validate-expressions.js';
19+
20+
type AnyRec = Record<string, unknown>;
21+
22+
const BROKEN = 'record.name ==';
23+
24+
function flow(edges: unknown[], extra: AnyRec = {}): AnyRec {
25+
return {
26+
name: 'edge_cel',
27+
label: 'Edge CEL',
28+
type: 'autolaunched',
29+
nodes: [
30+
{ id: 'start', type: 'start', label: 'Start' },
31+
{ id: 'decide', type: 'decision', label: 'Decide' },
32+
{ id: 'end', type: 'end', label: 'End' },
33+
],
34+
edges,
35+
...extra,
36+
};
37+
}
38+
39+
const EDGES = [
40+
{ id: 'e1', source: 'start', target: 'decide' },
41+
{ id: 'e2', source: 'decide', target: 'end', type: 'conditional', condition: BROKEN },
42+
];
43+
44+
describe('expression-invalid on a flow edge condition carries a key path (#26858)', () => {
45+
it('names the edge by its index in the authored list, where and message unchanged', () => {
46+
const issues = validateStackExpressions({ flows: [flow(EDGES)] });
47+
const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
48+
expect(onEdge.length).toBeGreaterThan(0);
49+
for (const i of onEdge) {
50+
expect(i.path).toBe('flows[0].edges[1].condition');
51+
expect(i.where).toBe("flow 'edge_cel' · edge 'e2' (decide→end) condition");
52+
}
53+
});
54+
55+
it('indexes the raw list: a null member before the edge does not renumber it', () => {
56+
const issues = validateStackExpressions({ flows: [flow([null, ...EDGES])] });
57+
const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
58+
expect(onEdge.length).toBeGreaterThan(0);
59+
for (const i of onEdge) expect(i.path).toBe('flows[0].edges[2].condition');
60+
});
61+
62+
it('indexes the flow in the stack', () => {
63+
const other = { ...flow([{ id: 'x1', source: 'start', target: 'end' }]), name: 'clean' };
64+
const issues = validateStackExpressions({ flows: [other, flow(EDGES)] });
65+
const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
66+
expect(onEdge.length).toBeGreaterThan(0);
67+
for (const i of onEdge) expect(i.path).toBe('flows[1].edges[1].condition');
68+
});
69+
70+
it('a map-form flows collection has no index to spell, so the finding keeps where alone', () => {
71+
const { name: _name, ...body } = flow(EDGES);
72+
const issues = validateStackExpressions({ flows: { edge_cel: body } });
73+
const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
74+
expect(onEdge.length).toBeGreaterThan(0);
75+
for (const i of onEdge) expect(i.path).toBeUndefined();
76+
});
77+
78+
it('a finding off the edge condition gains no path', () => {
79+
const issues = validateStackExpressions({
80+
flows: [
81+
flow([{ id: 'e1', source: 'start', target: 'end' }], {
82+
nodes: [
83+
{ id: 'start', type: 'start', label: 'Start', config: { condition: BROKEN } },
84+
{ id: 'end', type: 'end', label: 'End' },
85+
],
86+
}),
87+
],
88+
});
89+
expect(issues.length).toBeGreaterThan(0);
90+
for (const i of issues) expect(i.path).toBeUndefined();
91+
});
92+
93+
it('reaches the publish gate: the refusal is located at the edge condition, message unchanged', () => {
94+
const result = runRuntimeAuthoringRules({ type: 'flow', item: flow(EDGES) });
95+
const refusals = result.errors.filter((f) => f.rule === 'expression-invalid');
96+
expect(refusals.length).toBeGreaterThan(0);
97+
for (const f of refusals) {
98+
expect(f.path).toMatch(/^flows(\[0\]|\.edge_cel)\.edges\[1\]\.condition$/);
99+
expect(f.where).toBe("flow 'edge_cel' · edge 'e2' (decide→end) condition");
100+
}
101+
});
102+
});

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ export interface ExprIssue {
161161
* condition, which might be a flow variable). Absent ⇒ treat as `error`.
162162
*/
163163
severity?: 'error' | 'warning';
164+
/**
165+
* [#26858] The judged slot's key path in the stack, in the spelling the other
166+
* rules' findings carry (`flows[0].edges[1].condition`), beside the human
167+
* `where`. Set only where the slot has one: a top-level flow edge's
168+
* `condition`. Absent, the finding is located by `where` alone.
169+
*/
170+
path?: string;
164171
}
165172

166173
type AnyRec = Record<string, unknown>;
@@ -2511,6 +2518,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
25112518
objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex);
25122519
for (const flow of objectWrite ? [] : recordsOf(stack.flows)) {
25132520
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
2521+
// [#26858] The flow's index in the authored list, for a finding's key path
2522+
// (`recordsOf` hands an array's own members back). A map-form `flows` has
2523+
// no index to spell, so its findings keep `where` alone.
2524+
const flowIndex = Array.isArray(stack.flows) ? stack.flows.indexOf(flow) : -1;
25142525
// `Array.isArray` proves the LIST, never its MEMBERS — the sentence #15742
25152526
// removed from one reader below in this same file. A YAML `nodes:` item
25162527
// left empty deserialises to `null`, and `nodes.find(n => n.type === …)` on
@@ -2897,12 +2908,23 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28972908
// and not a check.
28982909
for (const edge of recordsOf(graph.edges)) {
28992910
const edgeCondWhere = `${at} · edge '${edge.id}' (${edge.source}→${edge.target}) condition`;
2911+
const first = issues.length;
29002912
if (!checkStructuralCondition(edgeCondWhere, edge.condition).refused) {
29012913
const from = issues.length;
29022914
check(edgeCondWhere, edge.condition, objectName);
29032915
warnShadowedFieldReads(edgeCondWhere, edge.condition);
29042916
refuseUnboundRoots(edgeCondWhere, edge.condition, from, 'predicate');
29052917
}
2918+
// [#26858] A top-level edge's findings also carry the slot's key path,
2919+
// indexed over the list the author wrote (the coerced one drops `null`
2920+
// members and would renumber). A nested region's edge keeps `where`.
2921+
const edgeIndex = flowIndex >= 0 && graph.path.length === 0 && Array.isArray(flow.edges)
2922+
? flow.edges.indexOf(edge)
2923+
: -1;
2924+
if (edgeIndex >= 0) {
2925+
const path = `flows[${flowIndex}].edges[${edgeIndex}].condition`;
2926+
for (let k = first; k < issues.length; k++) issues[k] = { ...issues[k]!, path };
2927+
}
29062928
}
29072929
// No `checkNullGuards` on node/edge conditions — and NOT for the reason
29082930
// #4811 first recorded (#4811 re-measured it). The stated blocker was the

0 commit comments

Comments
 (0)