Skip to content

Commit 02bdeaa

Browse files
os-elon-muskclaude
andauthored
fix(spec): filter the edge members collectFlowGraphs hands out (#18906)
Fixes #18102 Clause-②: no `collectFlowGraphs` handed out a `FlowGraph` whose `edges` could hold a non-record. The node side of that same walk has been member-filtered since the repair #16752 landed; the edge side was forwarded untouched, four lines away. One filter per list, side by side, in the same walk — so `FlowGraph.edges` means what its declared type says. ## What the card claimed, re-derived on this tree All three readings hold, and the line numbers were re-derived rather than copied. | card's reading | re-derived at | verdict | |---|---|---| | node side does `nodes.filter(isRegionDict)` around `:773` | `control-flow.zod.ts:773` | holds, exact | | `edges` forwarded untouched in the same `graphs.push` | `:774` | holds | | a nested region's edge list admitted on `Array.isArray` alone around `:786` | `:786` | holds, exact | The reproduction from the card body, run on `main` at `0b31d90fb`: ``` collectFlowGraphs({ nodes: [start, loop{ body: { nodes: [], edges: [null] } }], edges: [] }) graph[0] scope="" path=[] edges=[] graph[1] scope="loop 'lp' body" path=["nodes",1,"config","body"] edges=[null] NON-RECORD EDGE MEMBERS HANDED OUT: [null] ``` ## The shape of the repair - **A drop, in the producer.** `edges.filter(isRegionDict)`, through the same single predicate the node side uses — extended in this PR to name its third caller, so the two lists the walk hands out cannot drift from each other. Copy-on-write per list: a well-formed flow is handed back the very same arrays, by identity, and pays nothing. - **Not a refusal, and not a looser signature.** No authoring door's accept set moves. `FlowSchema.safeParse` still returns an envelope rather than throwing (the walk runs inside the parse, so the repair has to stay a drop and a skip), the region `safeParse` refusal in `validateControlFlow` still owns and still reports the malformed region, and `FlowGraph.path` still indexes the RAW node list so a Zod issue stays anchored where the author wrote it. The declared element type is unchanged; it is now true. - **No consumer indexes `graph.edges` positionally.** Both `packages/lint` readers key findings by `edge.id`; the edge-id uniqueness rule in `flow.zod.ts` reads `flow.edges` raw, never `graph.edges`. So a dropped member changes the index and never whether an edge was judged. ## Latent, not live — measured, and NOT for the reason the card gave The card's reachability argument rests on "#16910 re-pointed both `graph.edges` consumers in `packages/lint`". There are **three** `graph.edges` consumers on this tree, not two. | consumer | guard | |---|---| | `packages/lint/src/lint-flow-patterns.ts:1560` | `recordsOf(graph.edges)` | | `packages/lint/src/validate-expressions.ts:1462` | `recordsOf(graph.edges)` | | `packages/services/service-automation/src/engine.ts:9244` | **none** — reads `edge.id` / `edge.source` / `edge.target` straight off each member | The third one is shielded only by call ORDER: inside `registerFlow`, `canonicalizeStoredFlow` runs `validateControlFlow(parsed)` at `engine.ts:4021`, which refuses the malformed region before `validateFlowExpressions` is reached at `:4058`. Measured: ``` validateControlFlow(the card's repro) THROWS Error: loop 'lp' body: invalid region — Too small: expected array to have >=1 items; Invalid input: expected object, received null ``` So the conclusion "no reachable throw today" survives — by one belt more than was counted, and a belt made of validator ordering rather than of a member check. That is exactly the asymmetry this card was filed about, one layer up. After this change the declared type carries it, and the next consumer of this list needs neither a coercion nor a call-order argument. ⛔ Not written up as a crash report: no throw was measured on the tree. ## `analyzeRegion` — the card's open question, answered `analyzeRegion` (`:412`, `for (const e of edges)` at `:442`) **throws** on a non-record edge member, measured on all five shapes: ``` null THROWS TypeError: Cannot read properties of null (reading 'source') undefined THROWS TypeError: Cannot read properties of undefined (reading 'source') a string no throw — errors: ["edge 'undefined' source 'undefined' is not a region node", …] a number no throw — same an array no throw — same ``` **But it is not a consumer of `FlowGraph.edges`, and the card's "second consumer" framing does not hold.** Nothing on this tree routes producer output into it. Its three in-repo entry points: - `validateControlFlow:624` hands it `parsed.data` from a *successful* `slot.schema.safeParse` — the failure branch throws first, so its `edges` members are always parsed records; - `findRegionEntry` ← `AutomationEngine.runRegion(region: FlowRegionParsed)` — parsed; - `bpmn-mapping.ts:111` casts a raw `cfg(c).body` region in, which is a pre-existing raw-cast surface of that file and untouched here. It reads an edge list; it does not read `FlowGraph.edges`. Its declared parameter type is `FlowEdgeParsed[]`, so a throw on input that type excludes is not a defect at that door — hardening it would be the lenient-consumer direction Prime Directive #12 refuses, and the direction #16752's own note refuses ("the repair is a drop, never a looser signature"). Left alone deliberately. ## Where the pin went Beside #16752's, in `packages/spec/src/automation/region-normalization.test.ts` — the `packages/spec`-side pin the card said was the one still missing. Both halves of that claim were verified first: - `packages/lint/src/non-record-object-entry.test.ts` already carries the `flows[].nodes[].config.body.edges` arm (`underNestedRegionEdges`, in `SWEPT_COLLECTIONS`), so no arm is added there and that file is untouched; - #16752's own spec-side pin is the `describe('#16752 — a non-record member never reaches a returned graph')` block in `region-normalization.test.ts`, not in `control-flow.test.ts`. The new `describe('#18102 — …')` sits directly after it, under the same `#4347 — collectFlowGraphs` parent. 31 new cases: five shapes × (three nesting depths + an unguarded-dereference arm + an anti-vacuity arm), plus the card's own reproduction and a per-list identity arm. ## Verification Union run at `369c4cca5` (the merge commit; `origin/main` merged in at `600b1e2ab` before opening this PR). **Reverse verification — the pin can fail.** Mutation through `scripts/ablation-replace.mjs`, anchor hit asserted on disk, restore proven by blob equality with HEAD: ``` anchor "edges: keptEdges.length === edges.length ? edges : keptEdges," x1 -> x0 replace "edges," x0 -> x1 blob 38cf36c -> b293f7924509 (the mutation landed) result 23 failing tests, all inside the #18102 describe, zero elsewhere restore blob == HEAD (38cf36c) and `git diff HEAD` empty ``` Direction: **red**, as expected. The suite resolves `./control-flow.zod.js` relatively inside its own package, so the mutation reddened it with no rebuild — this ablation has no `dist/` leg to prove. **Tests.** | run | result | |---|---| | `pnpm --filter @objectstack/spec test` | 488 files / 14159 tests passed | | `pnpm --filter @objectstack/spec typecheck` | pass (test layer compiles; debt ledger unmoved) | | `pnpm --filter @objectstack/lint test` | 104 files / 3909 tests passed | | `... service-automation exec vitest run src/nested-region-parity.test.ts` | 22 tests passed | The `packages/lint` run is the load-bearing one for the cross-package direction: `non-record-object-entry.test.ts` keeps `RESIDUAL_THROWS` and `RESIDUAL_INVENTED` exact in BOTH directions, so a producer change that flipped a row there would red it. Both stay empty. **Gates.** Derived from the actual changed paths, never from a named list: ``` node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 81 families 81 run, every exit code captured to a file BEFORE any pipe, all 0 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt -> 81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 81 recorded an exit code and none of them is 3) ``` Two conditions were cleared rather than reported as reds, because neither measured anything: `check-plugin-teardown-shape --self-test` exited 3 on a shallow clone that could not reach its pinned fixture (cleared by `git fetch --unshallow`, as its own text prescribes), and four gates exited 3 with `PREREQUISITE NOT MET` before the package closure was built (cleared by `turbo run build` over `./packages/*`). The whole set was then re-run at the final `369c4cca5`, so no ratchet reading predates the last commit. ⛔ Derived families are not a complete account of CI on this PR: the 50 artifact-roster families, the 11 wide-population families, the 5 path-scheduled CI jobs and the always-runs tail are each outside that total, and a green from a `--self-test`-only family grades the checker, not this diff. **Lint, as a proven narrowing rather than a repo scan.** `pnpm lint` is `eslint . --no-inline-config`, a repo-wide scan CI owns. The three facts that make the narrow run a measurement: 1. **Population, read from `eslint.config.mjs` itself** — its file-matching objects declare `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and narrower per-root subsets of it, minus `NEVER_LINTED`. A `.md` path matches no config object, which eslint states in its own report for the changeset (`File ignored because no matching configuration was supplied`). 2. **Count, read from `--format json`** — 3 paths handed in, 3 in the report, 2 linted: 0 errors, 0 warnings, 0 suppressed. Exit 0. 3. **Invariance** — `eslint.config.mjs` states, with its own measured positive control, that this repo "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not". With no rule reading a type, nothing in this diff can move the verdict on a file this diff does not contain. ## Acceptance notes Observations, none of them filed — each is noted here for whoever next touches the file. - **Two `packages/lint` comments now misstate the producer.** `lint-flow-patterns.ts:1555` and `validate-expressions.ts:1459` each say the edge side of this walk "is not" member-filtered, as the justification for their `recordsOf` coercion. Their conclusion stays right (belt and braces, ⛔ not one repair written twice) — only the parenthetical is now stale. Left untouched because the dispatch's declared file surface does not include those two files and a stale comment is not one of the three filing classes. Exact edit needed: in both, "the edge side is not" becomes "both sides are since #18102". - **`non-record-object-entry.test.ts:378` carries the same stale sentence** in `underNestedRegionEdges`' docblock. Same reasoning; the arm's behaviour is unaffected and the file is untouched. - **The edge-id uniqueness rule is top-level only.** `flow.zod.ts:1164` walks `flow.edges` and no region's, so two region edges may share an id where two top-level edges may not. A coverage asymmetry, not an error: no example fails, nothing is mis-documented. Not filed. - **`bpmn-mapping.ts` casts raw region config into `analyzeRegion`.** `cfg(c).body as Region` at `:313` (and the `try`/`catch`/`branches` siblings) is a bare cast off an open `z.record`, so a malformed region reaches `analyzeRegion` as a `TypeError` where that file's own located diagnostic belongs. Reachability was NOT measured, so this stays an un-rehearsed observation rather than a card. Successor: none — no open PR touches that file. - #16910 is not addressed here, as the card and triage both state. ## 维护者速读(草稿) **改了什么。** `packages/spec` 里那个把一张 flow 拆成「每个子图」的函数,原先对节点列表做了成员过滤、对边列表没做。这次给边列表补上同一个过滤,一条路一个过滤器,并排放着。 **为什么改。** 它对外声明「我交出去的边一定是一条边」,实际可能交出一个 `null` —— 作者在 YAML 里把 `edges:` 下面一项写空了,反序列化出来就是 `null`。同一个函数、同一次遍历,节点那一半修了、边这一半没修,声明与事实不符的那一半就还在。今天没人被它崩到,但每个现在和将来的消费者都只差一次不设防的取值。本次实测还发现:挡住它的其实不止卡上写的那两道消费端强制转换,还有第三个消费者靠「校验顺序」侥幸躲开 —— 也就是说,原来的安全边界比卡上记的更薄。 **风险与代价(含回滚)。** 行为变化只发生在「声明里本来就说不可能存在」的输入上;结构良好的 flow 按引用原样返回,零代价。不新增任何拒收,作者能写的东西一点没少。回滚 = revert 本 PR 单个 commit,无数据迁移、无配置、无兼容窗口。 **席位意见。** **你要做的。** 确认这里只补过滤、不新增拒收(这是本卡的裁决前提),然后按常规流程合。若要顺手清掉上面 Acceptance notes 里那三处已过期的注释,那是另一个 PR 的事。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5ccfb4 commit 02bdeaa

3 files changed

Lines changed: 191 additions & 12 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`collectFlowGraphs` no longer hands out a `FlowGraph` whose `edges` can hold a non-record — the sibling list #16752's repair did not reach (#18102).
6+
7+
`FlowGraph.edges` is declared `readonly FlowEdgeParsed[]`. The walk forwarded it untouched, four lines from the node-side member filter the same walk has carried since #16752, and a nested region's edge list is admitted on `Array.isArray` alone — which proves the LIST and never its MEMBERS. A YAML `edges:` list item left empty deserialises to `null`, and a region its own schema refused is left RAW for `validateControlFlow` to name, so the producer handed out an array holding a member its own declared element type excludes. Measured on `main`:
8+
9+
```
10+
collectFlowGraphs({ nodes: [start, loop{ body: { nodes: [], edges: [null] } }], edges: [] })
11+
graph[1] scope="loop 'lp' body" edges=[null] declared readonly FlowEdgeParsed[]
12+
```
13+
14+
- **The junk member is DROPPED, per list**, through the same one predicate the node side uses (`isRegionDict`), so the two lists the walk hands out cannot drift from each other. Copy-on-write per list: a well-formed flow is handed back the very same arrays.
15+
- ⭐ **The real edge beside it is still HANDED OUT**, and so is the node list. "No non-record members" is half a contract — a filter that emptied `edges`, or reached into `nodes`, would satisfy it. Both are pinned.
16+
- **This is a drop in the producer, not a refusal.** No authoring door's accept set moves: `FlowSchema.safeParse` still returns an envelope rather than throwing, the region `safeParse` refusal in `validateControlFlow` still owns and still reports the malformed region, and `FlowGraph.path` still indexes the RAW node list so a Zod issue stays anchored where the author wrote it. ⛔ Not a looser signature either — the declared element type is unchanged and is now true.
17+
- **Latent, not live — measured, and not for the reason the filing gave.** There are THREE `graph.edges` consumers on the tree, not two. The two in `packages/lint` coerce through `recordsOf` (#16910). The third is `packages/services/service-automation`'s registration pass, which reads `.id` / `.source` / `.target` straight off each member with no guard, and is shielded only by call ORDER — `validateControlFlow` refuses the malformed region a few frames earlier in `registerFlow`. So no throw is reachable today, by one belt more than was counted. After this change the declared type carries it, and the next consumer needs neither a coercion nor a call-order argument.
18+
- **`analyzeRegion` is not one of those consumers.** It throws a `TypeError` on a `null` / `undefined` edge member (measured), but nothing routes producer output into it: its in-repo callers hand it post-`safeParse` region data. It reads an edge list, it does not read `FlowGraph.edges`.
19+
- **No behaviour changes on well-formed metadata.** The only input whose handling moves is input whose declared type already said it could not exist.
20+
21+
Clause-②: no

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

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -500,9 +500,10 @@ export function findRegionEntry(region: { nodes: FlowNodeParsed[]; edges?: FlowE
500500

501501
/**
502502
* A dict — region-shaped enough to reach its `nodes` / `edges`, and the same
503-
* test a member of a node list must pass to be a node at all. One spelling for
504-
* both, so what {@link collectFlowGraphs} walks cannot drift from what it hands
505-
* out.
503+
* test a member of a node list or an EDGE list must pass to be a node or an
504+
* edge at all. One spelling for all three, so what {@link collectFlowGraphs}
505+
* walks cannot drift from what it hands out, and the two lists it hands out
506+
* cannot drift from each other.
506507
*/
507508
function isRegionDict(v: unknown): v is Record<string, unknown> {
508509
return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -722,11 +723,25 @@ export interface FlowGraph {
722723
* item deserialises to `null` — and a region its own schema refused is left
723724
* RAW for {@link validateControlFlow} to name. The walk therefore drops a
724725
* non-record member rather than hand out an array that does not match this
725-
* declared type (#16752). Only this array is narrowed: the schema refusal
726-
* that owns the malformed region still fires, and {@link path} still indexes
727-
* the RAW list, so a finding stays anchored where the author wrote it.
726+
* declared type (#16752). Only the two handed-out arrays are narrowed: the
727+
* schema refusal that owns the malformed region still fires, and
728+
* {@link path} still indexes the RAW list, so a finding stays anchored where
729+
* the author wrote it.
728730
*/
729731
readonly nodes: readonly FlowNodeParsed[];
732+
/**
733+
* Every member is a record, for the reason {@link FlowGraph.nodes} states and
734+
* by the same drop in the same walk. A nested region's edge list is admitted
735+
* on `Array.isArray` alone, and `Array.isArray` proves the LIST, never its
736+
* MEMBERS: an empty YAML `edges:` item deserialises to `null`, and a region
737+
* its own schema refused is left RAW for {@link validateControlFlow} to name.
738+
* So this array was forwarded with the producer's word about its members
739+
* rather than a check, while its declared element type said it could not hold
740+
* one. The walk drops a non-record member here too. Only the handed-out array
741+
* is narrowed: the schema refusal that owns the malformed region still fires,
742+
* and no consumer indexes this list positionally — a dropped member changes
743+
* the index, never whether an edge was judged.
744+
*/
730745
readonly edges: readonly FlowEdgeParsed[];
731746
}
732747

@@ -759,24 +774,36 @@ export function collectFlowGraphs(
759774
// author typed — `null` included. What is HANDED OUT and what is WALKED both
760775
// drop it, through the one predicate above.
761776
//
762-
// Handed out (#16752): `FlowGraph.nodes` is declared `readonly
763-
// FlowNodeParsed[]`, and an array whose members every caller must re-check
764-
// is not that array. This list is one the walk picked up out of an open
765-
// `z.record` config ITSELF — no caller ever held it, so no coercion at a
766-
// call site can reach it. Identity is preserved when nothing is dropped.
777+
// Handed out (#16752 for `nodes`, #18102 for `edges`): both are declared
778+
// arrays of parsed records, and an array whose members every caller must
779+
// re-check is not that array. BOTH lists are ones the walk picked up out of
780+
// an open `z.record` config ITSELF — no caller ever held them, so no
781+
// coercion at a call site can reach them. The node side was repaired first
782+
// and the edge side was forwarded untouched for one list's worth of time;
783+
// one filter per list, side by side, is what keeps that from recurring.
784+
// Identity is preserved per list when nothing is dropped.
767785
//
768786
// Walked (#16134): skip a non-record rather than read `.config` off it —
769787
// this walk runs inside `FlowSchema`'s parse, where a thrown TypeError would
770788
// escape `safeParse`, which is why this is a skip and not a throw. The schema
771789
// refusal that owns the malformed region still fires, reached now where the
772790
// throw used to pre-empt it.
773791
const kept = nodes.filter((node) => isRegionDict(node));
774-
graphs.push({ scope, path, nodes: kept.length === nodes.length ? nodes : kept, edges });
792+
const keptEdges = edges.filter((edge) => isRegionDict(edge));
793+
graphs.push({
794+
scope,
795+
path,
796+
nodes: kept.length === nodes.length ? nodes : kept,
797+
edges: keptEdges.length === edges.length ? edges : keptEdges,
798+
});
775799
if (depth >= MAX_REGION_DEPTH) return;
776800
// Indexed over the RAW list, never `kept`: `path` anchors a Zod issue where
777801
// the author wrote the node, so dropping a member must not renumber the
778802
// siblings that outlive it. `Array.isArray` on the inner list below proves
779803
// the LIST, never its MEMBERS — the sentence removed from four lint readers.
804+
// It stays a list test on purpose: the member test for both lists is the
805+
// pair of filters above, run by the next depth's own `visit` on the way
806+
// out, so neither list can be handed out on an `Array.isArray` alone.
780807
nodes.forEach((node, index) => {
781808
if (!isRegionDict(node)) return;
782809
for (const slot of regionSlotsOf(node)) {

‎packages/spec/src/automation/region-normalization.test.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,4 +388,135 @@ describe('#4347 — collectFlowGraphs', () => {
388388
expect(collectFlowGraphs({ nodes: nodes as never, edges: [] })[0]!.nodes).toBe(nodes);
389389
});
390390
});
391+
392+
/**
393+
* #18102 — the SAME guarantee for the other list the walk hands out.
394+
*
395+
* `FlowGraph.edges` is declared `readonly FlowEdgeParsed[]` and was pushed
396+
* through untouched, four lines from the node-side filter #16752 added. A
397+
* nested region's edge list is admitted on `Array.isArray` alone, and
398+
* `Array.isArray` proves the LIST and never its MEMBERS — so a
399+
* `body: { edges: [null] }`, which is what a YAML `edges:` item left empty
400+
* deserialises to, became a graph whose `edges` held a `null` its own
401+
* declared element type excludes.
402+
*
403+
* ## Why this is a describe of its own and not a shape of the one above
404+
*
405+
* The two lists are read by the same walk out of the same open `z.record`
406+
* config, so nothing about the node-side pin could say the edge side was
407+
* unguarded: it addresses `nodes`. That is the whole lesson of this pair —
408+
* the repair landed on one of the two lists and the sibling beside it kept
409+
* the producer's word about its members for one list's worth of time. A
410+
* separate describe is what makes the second list ADDRESSABLE, the same
411+
* argument `non-record-object-entry.test.ts` makes for writing its sweep over
412+
* a table.
413+
*
414+
* ⛔ Latent, not live — and the pin does not claim otherwise. On the tree this
415+
* landed against, the two `graph.edges` readers in `packages/lint` coerce
416+
* through `recordsOf` (#16910) and `packages/services/service-automation`'s
417+
* registration pass reaches its unguarded `graph.edges` loop only AFTER
418+
* `validateControlFlow` has refused the malformed region. Belt, not braces:
419+
* what is pinned here is that the declared element type is now TRUE, so the
420+
* next consumer to read this list needs neither coercion nor a call-order
421+
* argument. ⛔ The repair is a drop, never a looser signature, and never a new
422+
* refusal — the accept set of every authoring door is unchanged.
423+
*/
424+
describe('#18102 — a non-record edge member never reaches a returned graph', () => {
425+
/** The same five shapes the node-side pin sweeps; `null` is the authored one. */
426+
const NON_EDGES: readonly (readonly [string, unknown])[] = [
427+
['null', null],
428+
['undefined', undefined],
429+
['a string', 'x'],
430+
['a number', 42],
431+
['an array', []],
432+
];
433+
434+
const isRecord = (v: unknown): boolean => typeof v === 'object' && v !== null && !Array.isArray(v);
435+
436+
/**
437+
* `depth` nested loop bodies, the innermost holding `junk` in its EDGE list
438+
* beside one real edge and the two real nodes that edge connects. Depth 0 is
439+
* the flow's own list: a caller could coerce that one, but `FlowGraph.edges`
440+
* draws no depth distinction, so neither does the filter or this pin.
441+
*/
442+
const nestedJunkEdge = (depth: number, junk: unknown) => {
443+
let region: Record<string, unknown> = {
444+
nodes: [{ ...gate, id: 'gate_in' }, { ...write, id: 'write_in' }],
445+
edges: [junk, { id: 'b_in', source: 'gate_in', target: 'write_in', type: 'default' }],
446+
};
447+
for (let i = depth; i > 0; i--) {
448+
region = { nodes: [loopWith(region, `lp${i}`)], edges: [] };
449+
}
450+
return { nodes: region.nodes as never, edges: region.edges as never };
451+
};
452+
453+
describe.each(NON_EDGES)('with %s in the innermost edge list', (_label, junk) => {
454+
// 0 is the flow's own list, 1 the shape the card reproduced, and 32 the
455+
// depth ceiling — where `visit` pushes a graph and returns without ever
456+
// walking its members, so the junk was handed out with nothing having
457+
// looked at it.
458+
it.each([0, 1, 32])('hands out only records at nesting %i', (depth) => {
459+
const graphs = collectFlowGraphs(nestedJunkEdge(depth, junk));
460+
expect(graphs.flatMap(g => g.edges).filter(e => !isRecord(e))).toEqual([]);
461+
});
462+
463+
it('lets an unguarded consumer dereference every edge it was handed', () => {
464+
// The defect stated as the consumer's own operation: `analyzeRegion` and
465+
// the engine's registration pass both read `.id` / `.source` / `.target`
466+
// straight off a member, which is what a declared element type is FOR.
467+
// This arm is what `null` and `undefined` fail before the filter; the
468+
// shape assertion above is what the other three fail.
469+
const graphs = collectFlowGraphs(nestedJunkEdge(1, junk));
470+
expect(() => graphs.flatMap(g => g.edges).map(e => `${e.id}:${e.source}→${e.target}`)).not.toThrow();
471+
});
472+
473+
it('still hands out the real edge, and the node list, standing beside it', () => {
474+
// Anti-vacuity, twice: the drop takes what cannot be read, not the list
475+
// — and it is per list, so a guard that emptied `edges` or reached into
476+
// `nodes` would pass the assertions above.
477+
const innermost = collectFlowGraphs(nestedJunkEdge(1, junk)).at(-1)!;
478+
expect(innermost.edges.map(e => e.id)).toEqual(['b_in']);
479+
expect(innermost.nodes.map(n => n.id)).toEqual(['gate_in', 'write_in']);
480+
});
481+
482+
it('lets `FlowSchema.safeParse` return an envelope rather than throw (#16134)', () => {
483+
// This walk runs inside the parse, so the repair has to stay a drop; a
484+
// throw here escapes `safeParse` instead of becoming an issue.
485+
const result = FlowSchema.safeParse({
486+
name: 'repro', label: 'Repro', type: 'schedule',
487+
nodes: [
488+
{ id: 'start', type: 'start', label: 'Start' },
489+
loopWith({ nodes: [{ ...gate, id: 'gate_in' }, { ...write, id: 'write_in' }], edges: [junk] }),
490+
],
491+
edges: [],
492+
});
493+
expect(typeof result.success).toBe('boolean');
494+
});
495+
});
496+
497+
it('hands out no non-record edge for the shape the card reproduced', () => {
498+
// Verbatim from the filing, which is also the minimal repro: an empty
499+
// `nodes` list still descends (`Array.isArray([])` holds), so the region
500+
// graph exists and its `edges` is the array under test.
501+
const graphs = collectFlowGraphs({
502+
nodes: [
503+
{ id: 'start', type: 'start', config: {} },
504+
{ id: 'lp', type: LOOP_NODE_TYPE, config: { collection: 'x', body: { nodes: [], edges: [null] } } },
505+
] as never,
506+
edges: [],
507+
});
508+
expect(graphs.map(g => g.path)).toEqual([[], ['nodes', 1, 'config', 'body']]);
509+
expect(graphs[1]!.edges).toEqual([]);
510+
});
511+
512+
it('hands back the very same arrays when there is nothing to drop', () => {
513+
// Copy-on-write per list, as the node side already was: a well-formed flow
514+
// pays nothing for either guard.
515+
const nodes = [{ ...gate }, { ...write }];
516+
const edges = [{ id: 'b1', source: 'gate', target: 'write', type: 'default' }];
517+
const graph = collectFlowGraphs({ nodes: nodes as never, edges: edges as never })[0]!;
518+
expect(graph.edges).toBe(edges);
519+
expect(graph.nodes).toBe(nodes);
520+
});
521+
});
391522
});

0 commit comments

Comments
 (0)