Skip to content

Commit eb05ea2

Browse files
feat(spec): validateControlFlow throws FlowRegionShapeError carrying the container path and node id (#29789)
domain:spec#3 · os-support-ai · session_01WGufzj9yXiuW9h8YrpyLMy <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBBvdbwqo6Exhkx4VrtNQBPA)_ Fixes #29719 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — `skills/**` has 0 hits for `validateControlFlow`; no skill teaches the refusal's type, and the message every skill-facing surface prints is unchanged ## 维护者速读 - 改了什么:流程结构校验 `validateControlFlow` 拒收一个流程时,抛出的不再是裸 `Error`,而是新导出的 `FlowRegionShapeError`,错误上带两项数据:出错容器节点在流程里的路径 `path`(如 `['nodes', 1, 'config', 'try', 'nodes', 0]`)和该节点的 `nodeId`。错误文字一个字节都没变。 - 为什么改:lint 规则 `flow-region-shape-invalid`(#29558)只能把问题挂在整个流程上;要定位到具体容器就得去解析报错句子,那是第二套判定写法,而且嵌套在别的容器里的 `parallel` 句子根本不带外层位置。现在读 `err.path` 即可。 - 风险与回滚:只加数据不改判定:哪些流程被拒、拒收文字、HTTP 答复(仍是 400 VALIDATION_FAILED,错误不带 `status`)都不变;类继承 `Error`,读 `err.message` 或 `instanceof Error` 的调用方无感。回滚即还原本 PR。 - 先看:`packages/spec/src/automation/control-flow.zod.ts`(新类与 `containerPathOf`:索引按原始列表取,不按过滤后的 `graph.nodes`)、`packages/spec/src/automation/control-flow.test.ts`(末尾 5 个新 pin)、`.changeset/29719-flow-region-shape-error.md`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/automation/control-flow.test.ts`,应见 56 个用例全绿;把 `control-flow.zod.ts` 换回 `origin/main` 版本再跑,新增 5 个用例全红。 - 需要你做的:无,席位落地。 ## What changes `validateControlFlow` (`packages/spec/src/automation/control-flow.zod.ts`) throws the new exported `FlowRegionShapeError` at all three of its throw sites. The class extends `Error` and adds two readonly fields: - `path: readonly (string | number)[]`: the key path from the flow root to the container node that owns the fault. It is a `FlowGraph.path` that `collectFlowGraphs` hands out, plus `['nodes', i]`, so a reader that already anchors on a graph's path can use it as is. Examples: `['nodes', 1]` for a top-level container, `['nodes', 1, 'config', 'try', 'nodes', 0]` for a container inside a `try` region, `['nodes', 1, 'config', 'branches', 1, 'nodes', 1]` for one inside parallel branch 1. - `nodeId: string`: that container's `id`. **Name and precedent.** `FlowRegionShapeError`, the seat's call under the veto window (claim 6594934995). Shape mirrored from `ListViewGroupQueryError` (`src/ui/view-grouping-query.ts`): an `Error` subclass with readonly data fields, `this.name` set to the class name, the message passed through unchanged, and `path` as a `(string | number)` key path. Like `MigrationFloorError` (`src/migrations/chain.ts`) it carries no ADR-0112 `code`/`status`. That is deliberate: the runtime's `flowDefinitionRefusal` (`packages/runtime/src/domains/automation.ts`) keeps a thrown error's own `.status` when it has one, so a declared status would overrule the door's 400 classification. Not mirrored: `ListViewGroupQueryError`'s closed `reason`. The card asks for path and node id only, and a refusal vocabulary would be one more export to review. **The index is the raw list's.** `collectFlowGraphs` drops a non-record member from the `nodes` it hands out, while `FlowGraph.path` keeps indexing the raw list. `containerPathOf` therefore reads the raw list back through `graph.path` and finds the node by identity (the walk filters and never copies), so a dropped member before the container does not renumber it. The lookup runs only on a refusal, so the accepting path does no extra work. **One judge, one spelling.** No new validation path: the same loop, the same three conditions and the same sentences. The only edits are `new Error(m)` → `new FlowRegionShapeError(path, id, m)` and the docblock. ### Throw sites | Site on `origin/main` `3eeb81d1` | Refusal | Before | Now carries | |:---|:---|:---|:---| | `control-flow.zod.ts:651` | `parallel 'ID': a parallel block needs at least 2 branches` | plain `Error`; the sentence names no enclosing scope | `path` to the `parallel` node, at any depth; `nodeId` | | `control-flow.zod.ts:657` | `WHERE: invalid region — ZOD ISSUES` (the slot's schema refuses it) | plain `Error`; location in prose only | `path` to the container that owns the slot; `nodeId` | | `control-flow.zod.ts:665` | `WHERE: ANALYSIS ERRORS` (`analyzeRegion`: entry/exit/cycle/ids/edges) | plain `Error`; location in prose only | `path` to the container that owns the slot; `nodeId` | | `control-flow.zod.ts:532` `findRegionEntry` | `malformed control-flow region: …` | unchanged, still a plain `Error` | not covered: see below | **`:532` is a separate judge, not on `validateControlFlow`'s path** (the PM assumption, measured). `validateControlFlow` never calls `findRegionEntry`. Its one production caller is the engine's `runRegion` in `packages/services/service-automation/src/dag-traversal.ts:761`, at run time, and it receives a bare region with no flow around it, so it has no flow path or container id to carry. A flow that reaches it registered already. This PR leaves it as it is. ## Tests `control-flow.test.ts` gains `validateControlFlow throws FlowRegionShapeError with the container path and id`. Every case asserts `instanceof FlowRegionShapeError`, `instanceof Error`, `name`, `path`, `nodeId` and the full message: | Case | `path` | `nodeId` | Message pinned | |:---|:---|:---|:---| | invalid region: top-level `try_catch` with an unknown region key | `['nodes', 1]` | `tc` | literal `try_catch 'tc' try: invalid region — ` + `FlowRegionSchema`'s own issue messages | | region analysis error: a two-entry `loop` body, inside parallel branch 1 | `['nodes', 1, 'config', 'branches', 1, 'nodes', 1]` | `sweep` | literal `parallel 'fan' branch 1 → loop 'sweep' body: ` + `analyzeRegion`'s errors (plus a literal `region must be single-entry but has 2: x, y; ` lead) | | `parallel` with 1 branch, top level | `['nodes', 0]` | `p` | literal, whole sentence | | the nested case: a 1-branch `parallel` inside a `try` region (the draft-publish-verdict fixture shape) | `['nodes', 1, 'config', 'try', 'nodes', 0]` | `fan` | literal `parallel 'fan': a parallel block needs at least 2 branches` | | raw index: `null` before the container in the list | `['nodes', 2]` | `p` | (not a message case) | The first four also assert one spelling: `path` minus its last two keys is a path that `collectFlowGraphs(flow)` hands out, and the second-to-last key is `'nodes'`. **Red first.** With `control-flow.zod.ts` checked out from `origin/main`, the five new cases fail (`Tests 5 failed | 51 passed (56)`, lock VERDICT command-exit 1). Restored with `git checkout HEAD --`: blob hash equal to HEAD's, `git diff HEAD` empty. **Ablation of the raw index** (`scripts/ablation-replace.mjs`, anchor hit 1 → 0, blob `0ac7eeaa` → `370278a2`): `raw.indexOf(node)` → `graph.nodes.indexOf(node)`. Only the raw-index case fails (`Tests 1 failed | 55 passed (56)`). Restore checked by blob equal to HEAD and an empty `git diff HEAD`. The spec suite imports `src/` directly, so no `dist/` is involved. **Message parity, measured.** A one-off probe (not committed) ran `origin/main`'s `validateControlFlow` and this head's over six refused flows: the five cases above plus a multi-exit loop body. Result: `byte-identical messages: 6/6`. **The pinned sentence holds.** `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts` pins `"Flow 'nested_region' would be refused at registration: parallel 'fan': a parallel block needs at least 2 branches"`. The `@objectstack/metadata-protocol` suite ran green on this head against a spec `dist` built from it (see Verification). ## Acceptance notes - **Changeset level: `patch`, not the dispatch's assumed `minor`.** AGENTS.md Post-Task step 3: "The level follows the kind alone: a kind takes at least `minor` …, `none` takes `patch`". An exported error class is not authorable metadata, so `Metadata-protocol: none` and `@objectstack/spec: patch`. `check-changeset-no-major` on this body: see Verification. The control with `minor` is refused `none-minor`. Precedent: `.changeset/26464-mcp-metadata-write-scope.md` (`Clause-②: yes (widening)`, `none`, spec `patch`). - Declaration map: `check:generated` reported it fresh (`check:declaration-map ✓`). Only `api-surface/automation.json` and `export-origins/automation.json` moved, one line each. - Consumer: #29558 (lint half) reads `err.path` when present, else keeps the flow as the anchor. `packages/lint/**` is untouched here. ## Verification All on head `78d8ed02`, under `os-verify-lock`. <details><summary>spec local suite + typecheck, metadata-protocol suite</summary> - Closure build `pnpm --filter '@objectstack/metadata-protocol^...' run build`: exit 0 (spec `dist` built from this head). - `pnpm --filter @objectstack/spec test`: `Test Files 803 passed (803)`, `Tests 23805 passed | 1 todo (23806)`, exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 310 passed | 4 skipped (314)`, `Tests 22292 passed | 26 skipped (22318)`, exit 0. This includes `protocol.draft-publish-verdict.test.ts` and its pinned nested-parallel sentence. - Lock: `VERDICT command-exit 0 · held the lock 843s`. - Spec `test:repo` was not run locally (CI's `Test Core` owns it). The diff edits no `repo`-project test, and the derived list does not name it. </details> <details><summary>generated artifacts</summary> - `pnpm --filter @objectstack/spec build && … check:generated`: `✗ 2 of 14 artifact(s) stale` (`check:api-surface`, `check:export-origins`). - `check:generated --fix` rewrote those two, one line each. The re-check printed all 14 `✓`, `check:declaration-map` included. Lock VERDICT command-exit 0. </details> <details><summary>check-changeset-no-major with this body as the event</summary> - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT`: exit 0, `✓ LEVEL AXIS: … metadata-protocol none … a non-protocol change at patch`. - Control (a local probe commit that was reset and never pushed: the changeset graded `@objectstack/spec: minor`, same body): exit 1, `Check Changeset (level axis): metadata-protocol declares none while a changeset entry is graded minor or above`. </details> <details><summary>derived gates (dispatch-gates --commands, 80 families)</summary> - 77 of 80 green on the first serial pass. - 3 exited 3 for missing prerequisites: - `check-plugin-teardown-shape --self-test` needed its pinned fixture commit `621a4876` in this shallow clone. - `check:dual-build-cjs-loads` and `check:lean-entry-closure` read built output of packages outside the closure. - After `git fetch origin 621a487…` and a full `pnpm turbo run build --concurrency=2` (`Tasks: 118 successful, 118 total`, lock VERDICT command-exit 0), all three exited 0. - `dispatch-gates --ran`: `✓ 80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASURED`. - CI families: see this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzj9yXiuW9h8YrpyLMy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 339f63d commit eb05ea2

5 files changed

Lines changed: 203 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): `validateControlFlow` throws `FlowRegionShapeError`, carrying the faulty container's path and node id
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`validateControlFlow` (`@objectstack/spec/automation`) now throws an exported `FlowRegionShapeError` at each of its three refusals: a region its schema refuses, a region that is not single-entry, single-exit and acyclic, and a `parallel` block with fewer than two branches. The error carries `path`, the key path from the flow root to the container node that owns the fault (the spelling of `collectFlowGraphs`' `FlowGraph.path` plus `['nodes', i]`, for example `['nodes', 1, 'config', 'try', 'nodes', 0]`), and `nodeId`, that container's id. A reader can now locate the fault without parsing the sentence, including a `parallel` block nested inside another container, whose sentence names no enclosing scope.
11+
12+
The message is unchanged byte for byte, and the class extends `Error`, so a caller that reads `err.message` or tests `instanceof Error` sees no difference. Nothing a flow author writes is accepted or refused differently.

‎packages/spec/api-surface/automation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
"FlowRegionArity (type)",
180180
"FlowRegionParsed (type)",
181181
"FlowRegionSchema (const)",
182+
"FlowRegionShapeError (class)",
182183
"FlowRegionSlotDecl (interface)",
183184
"FlowRunGateSummary (type)",
184185
"FlowRunGateSummarySchema (const)",

‎packages/spec/export-origins/automation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"FlowRegionArity": "src/automation/region-slots.ts#FlowRegionArity (type)",
174174
"FlowRegionParsed": "src/automation/control-flow.zod.ts#FlowRegionParsed (type)",
175175
"FlowRegionSchema": "src/automation/control-flow.zod.ts#FlowRegionSchema (const)",
176+
"FlowRegionShapeError": "src/automation/control-flow.zod.ts#FlowRegionShapeError (class)",
176177
"FlowRegionSlotDecl": "src/automation/region-slots.ts#FlowRegionSlotDecl (interface)",
177178
"FlowRunGateSummary": "src/automation/execution.zod.ts#FlowRunGateSummary (type)",
178179
"FlowRunGateSummarySchema": "src/automation/execution.zod.ts#FlowRunGateSummarySchema (const)",

‎packages/spec/src/automation/control-flow.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
analyzeRegion,
1313
findRegionEntry,
1414
validateControlFlow,
15+
collectFlowGraphs,
16+
FlowRegionShapeError,
1517
LOOP_MAX_ITERATIONS_CEILING,
1618
LOOP_NODE_TYPE,
1719
PARALLEL_NODE_TYPE,
@@ -643,3 +645,114 @@ describe('validateControlFlow and the unknown-key gate meet at the region-slot s
643645
expect(message).toContain('A region is not labelled');
644646
});
645647
});
648+
649+
// ─── [#29719] The refusal carries the container's location as data ─────────
650+
//
651+
// `validateControlFlow` throws a `FlowRegionShapeError` at each of its three
652+
// throw sites: the container's `path` (spelled as `collectFlowGraphs`'
653+
// `FlowGraph.path`, plus `['nodes', i]`) and its `nodeId` ride on the error,
654+
// so a reader such as the `flow-region-shape-invalid` lint rule anchors the
655+
// fault without parsing the sentence. The sentence itself is unchanged: each
656+
// case pins the engine's own words literally and derives only the part a
657+
// judge it calls (Zod, `analyzeRegion`) words.
658+
describe('validateControlFlow throws FlowRegionShapeError with the container path and id', () => {
659+
const refusalOf = (flow: unknown): FlowRegionShapeError => {
660+
try {
661+
validateControlFlow(flow as never);
662+
} catch (err) {
663+
expect(err).toBeInstanceOf(FlowRegionShapeError);
664+
expect(err).toBeInstanceOf(Error);
665+
expect((err as Error).name).toBe('FlowRegionShapeError');
666+
return err as FlowRegionShapeError;
667+
}
668+
throw new Error('expected validateControlFlow to refuse the flow');
669+
};
670+
/** The path's graph half must be a path `collectFlowGraphs` hands out — one spelling. */
671+
const expectGraphPathSpelling = (flow: unknown, path: readonly (string | number)[]) => {
672+
const graphPaths = collectFlowGraphs(flow as never).map((g) => JSON.stringify(g.path));
673+
expect(path.slice(-2)[0]).toBe('nodes');
674+
expect(graphPaths).toContain(JSON.stringify(path.slice(0, -2)));
675+
};
676+
const loopCfg = (body: unknown) => ({ collection: { dialect: 'cel', source: 'items' }, iteratorVariable: 'item', body });
677+
const twoStep = { nodes: [node('a'), node('b')], edges: [edge('e1', 'a', 'b')] };
678+
679+
it('an invalid region (the region schema refuses it): a top-level try_catch', () => {
680+
const tryRegion = { nodes: [node('a')], edges: [], bogus: true };
681+
const flow = { nodes: [node('start', 'start'), { ...node('tc', TRY_CATCH_NODE_TYPE), config: { try: tryRegion } }] };
682+
const err = refusalOf(flow);
683+
expect(err.path).toEqual(['nodes', 1]);
684+
expect(err.nodeId).toBe('tc');
685+
const issues = FlowRegionSchema.safeParse(tryRegion).error!.issues.map((i) => i.message).join('; ');
686+
expect(err.message).toBe(`try_catch 'tc' try: invalid region — ${issues}`);
687+
expectGraphPathSpelling(flow, err.path);
688+
});
689+
690+
it('a region analysis error: a loop nested in a parallel branch', () => {
691+
const body = { nodes: [node('x'), node('y')], edges: [] }; // two entries, two exits
692+
const flow = {
693+
nodes: [
694+
node('start', 'start'),
695+
{
696+
...node('fan', PARALLEL_NODE_TYPE),
697+
config: {
698+
branches: [
699+
twoStep,
700+
{ nodes: [node('c'), { ...node('sweep', LOOP_NODE_TYPE), config: loopCfg(body) }], edges: [edge('e2', 'c', 'sweep')] },
701+
],
702+
},
703+
},
704+
],
705+
};
706+
const err = refusalOf(flow);
707+
expect(err.path).toEqual(['nodes', 1, 'config', 'branches', 1, 'nodes', 1]);
708+
expect(err.nodeId).toBe('sweep');
709+
expect(err.message).toBe(
710+
`parallel 'fan' branch 1 → loop 'sweep' body: ${analyzeRegion(body as never).errors.join('; ')}`,
711+
);
712+
expect(err.message.startsWith("parallel 'fan' branch 1 → loop 'sweep' body: region must be single-entry but has 2: x, y; ")).toBe(true);
713+
expectGraphPathSpelling(flow, err.path);
714+
});
715+
716+
it('a parallel block with fewer than 2 branches, at the top level', () => {
717+
const flow = { nodes: [{ ...node('p', PARALLEL_NODE_TYPE), config: { branches: [twoStep] } }] };
718+
const err = refusalOf(flow);
719+
expect(err.path).toEqual(['nodes', 0]);
720+
expect(err.nodeId).toBe('p');
721+
expect(err.message).toBe("parallel 'p': a parallel block needs at least 2 branches");
722+
expectGraphPathSpelling(flow, err.path);
723+
});
724+
725+
it('the nested case: a one-branch parallel inside a try region, whose sentence names no scope', () => {
726+
const flow = {
727+
nodes: [
728+
node('start', 'start'),
729+
{
730+
...node('guard', TRY_CATCH_NODE_TYPE),
731+
config: {
732+
try: {
733+
nodes: [{ ...node('fan', PARALLEL_NODE_TYPE), config: { branches: [{ nodes: [node('only_step')], edges: [] }] } }],
734+
edges: [],
735+
},
736+
catch: { nodes: [node('catch_step')], edges: [] },
737+
},
738+
},
739+
],
740+
};
741+
const err = refusalOf(flow);
742+
// The sentence the draft-publish verdict pins, byte for byte.
743+
expect(err.message).toBe("parallel 'fan': a parallel block needs at least 2 branches");
744+
expect(err.path).toEqual(['nodes', 1, 'config', 'try', 'nodes', 0]);
745+
expect(err.nodeId).toBe('fan');
746+
expectGraphPathSpelling(flow, err.path);
747+
});
748+
749+
it('the index is the RAW list\'s: a dropped non-record member does not renumber the container', () => {
750+
// `collectFlowGraphs` hands out `nodes` without the `null`, while
751+
// `FlowGraph.path` keeps indexing the raw list; the container's index follows
752+
// `path`, so it reads 2 here, not 1.
753+
const flow = { nodes: [node('start', 'start'), null, { ...node('p', PARALLEL_NODE_TYPE), config: { branches: [twoStep] } }] };
754+
const err = refusalOf(flow);
755+
expect(err.path).toEqual(['nodes', 2]);
756+
expect(err.nodeId).toBe('p');
757+
});
758+
});

‎packages/spec/src/automation/control-flow.zod.ts‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -621,8 +621,44 @@ function singularize(key: string): string {
621621
const MAX_REGION_DEPTH = 32;
622622

623623
/**
624-
* Validate every structured control-flow construct in a flow, throwing on the
625-
* first malformed region (ADR-0031 — "reject the malformed before run"). Covers
624+
* The refusal {@link validateControlFlow} throws: a malformed ADR-0031 region,
625+
* or a `parallel` block with fewer than two branches.
626+
*
627+
* `message` is the engine's sentence, unchanged — the registration log, the
628+
* `registerFlow` answer and every pinned copy of it read the same text they
629+
* always did. What the class adds is the location as data, so a reader can
630+
* anchor the fault without parsing that sentence (which would be a second
631+
* spelling of this judge, and could not place a nested `parallel` at all —
632+
* its sentence names no enclosing scope):
633+
*
634+
* - `path` — the key path from the flow root to the faulty CONTAINER node,
635+
* spelled as {@link FlowGraph.path} is plus `['nodes', i]`:
636+
* `['nodes', 1]` for a top-level container,
637+
* `['nodes', 1, 'config', 'try', 'nodes', 0]` for one inside a `try` region.
638+
* - `nodeId` — that container's `id`.
639+
*
640+
* Both name the container that owns the faulty region (or, for the branch
641+
* count, the `parallel` block itself); which of its slots is at fault stays in
642+
* the sentence, as before. Carries no `status`: `registerFlow`'s HTTP doors
643+
* class every refusal of the definition as the author's fault on their own,
644+
* and a declared status would overrule that.
645+
*/
646+
export class FlowRegionShapeError extends Error {
647+
readonly path: readonly (string | number)[];
648+
readonly nodeId: string;
649+
650+
constructor(path: readonly (string | number)[], nodeId: string, message: string) {
651+
super(message);
652+
this.name = 'FlowRegionShapeError';
653+
this.path = path;
654+
this.nodeId = nodeId;
655+
}
656+
}
657+
658+
/**
659+
* Validate every structured control-flow construct in a flow, throwing a
660+
* {@link FlowRegionShapeError} on the first malformed region (ADR-0031 —
661+
* "reject the malformed before run"). Covers
626662
* `loop` bodies, `parallel` branches, and `try_catch` try/catch regions —
627663
* **at every depth** (#4389), so a container nested inside another container's
628664
* region is checked too. Only validates the *nested structure* when present, so
@@ -648,27 +684,62 @@ export function validateControlFlow(flow: { nodes: FlowNodeParsed[] }): void {
648684
const cfg = node.config as Record<string, unknown> | undefined;
649685
if (!cfg) continue;
650686
if (node.type === PARALLEL_NODE_TYPE && Array.isArray(cfg.branches) && cfg.branches.length < 2) {
651-
throw new Error(`parallel '${node.id}': a parallel block needs at least 2 branches`);
687+
throw new FlowRegionShapeError(
688+
containerPathOf(flow, graph, node),
689+
node.id,
690+
`parallel '${node.id}': a parallel block needs at least 2 branches`,
691+
);
652692
}
653693
for (const slot of regionSlotsOf(node)) {
654694
const where = graph.scope ? `${graph.scope} → ${slot.label}` : slot.label;
655695
const parsed = slot.schema.safeParse(slot.raw);
656696
if (!parsed.success) {
657-
throw new Error(
697+
throw new FlowRegionShapeError(
698+
containerPathOf(flow, graph, node),
699+
node.id,
658700
`${where}: invalid region — ${parsed.error.issues.map(i => i.message).join('; ')}`,
659701
);
660702
}
661703
// Both region schemas produce `{ nodes, edges }` (a parallel branch adds
662704
// `name` — a superset); `z.ZodTypeAny` just cannot say so.
663705
const analysis = analyzeRegion(parsed.data as { nodes: FlowNodeParsed[]; edges?: FlowEdgeParsed[] });
664706
if (analysis.errors.length > 0) {
665-
throw new Error(`${where}: ${analysis.errors.join('; ')}`);
707+
throw new FlowRegionShapeError(
708+
containerPathOf(flow, graph, node),
709+
node.id,
710+
`${where}: ${analysis.errors.join('; ')}`,
711+
);
666712
}
667713
}
668714
}
669715
}
670716
}
671717

718+
/**
719+
* The key path from the flow root to `node`, a member of `graph`: `graph.path`
720+
* plus `['nodes', i]` — the spelling {@link FlowGraph.path} uses, so a reader
721+
* can hand a {@link FlowRegionShapeError} straight to whatever already anchors
722+
* on a graph's path.
723+
*
724+
* `i` is the node's index in the RAW list, read back through `graph.path`, not
725+
* its position in `graph.nodes`: the walk drops a non-record member from what
726+
* it hands out while `path` keeps indexing the raw list (the reason
727+
* {@link FlowGraph.nodes} states), so a position in the handed-out array would
728+
* renumber the siblings after a dropped member. A handed-out member is the raw
729+
* member itself (the walk filters, it never copies), so identity finds it.
730+
* Reached only on a refusal, so the happy path pays nothing for it.
731+
*/
732+
function containerPathOf(
733+
flow: { nodes: FlowNodeParsed[] },
734+
graph: FlowGraph,
735+
node: FlowNodeParsed,
736+
): readonly (string | number)[] {
737+
let holder: unknown = flow;
738+
for (const key of graph.path) holder = (holder as Record<string | number, unknown>)[key];
739+
const raw = (holder as { nodes: readonly unknown[] }).nodes;
740+
return [...graph.path, 'nodes', raw.indexOf(node)];
741+
}
742+
672743

673744
// ─── Region parsing (the FlowNodeSchema transform) ───────────────────
674745

0 commit comments

Comments
 (0)