fix(dashboard): drop ELK edges that reference nodes removed by orphan/cycle repair passes#456
fix(dashboard): drop ELK edges that reference nodes removed by orphan/cycle repair passes#456tirth8205 wants to merge 1 commit into
Conversation
…/cycle repair passes The orphan-edge pass validated edges against an id set walked from the pre-removal child tree (childrenB), so an edge whose endpoint is a node later dropped by the orphan-child or containment-cycle passes survived, pointing at a node no longer present in children. ELK can throw on such dangling input, tripping the elk-layout-failed fatal path. Move the orphan-edge pass to run after the containment-cycle pass and validate edges against a freshly-walked id set from the final child tree (childrenD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thejesh23
left a comment
There was a problem hiding this comment.
A few concerns before this lands.
1. Layout is still the de-facto deduper. Step 3 (orphan-child) and step 4 (cycle) know they're removing nodes but don't drop their own dangling edges — they leave it to step 5 to clean up. Worse, when step 3 filters a parent it drops c.children wholesale, so every edge into that subtree's descendants gets silently dropped by step 5 too. If the orphan/cycle passes returned {children, removedIds} the edge filter could be local to each pass and the reason for each drop would be recoverable.
2. Observability is lossy. The issue message is "Dropped N edge(s) referencing nonexistent nodes" and the category is elk-orphan-edge regardless of whether the missing endpoint was an original ghost, an orphan-child drop, or a cycle drop. Combined with appendLayoutIssues deduping by level|message (store.ts:776), a second layout run that drops a different single edge produces the same string and is swallowed — so diff-impact re-layouts won't surface fresh losses. At minimum, include edge ids or the missing-endpoint id in the message.
3. Test gaps. The new test covers the orphan-child case but not the cycle case (step 4 removing a node that an edge targets), nor the cascading case where step 3 drops a parent and an edge into a now-vanished grandchild gets quietly dropped by step 5. Both are reachable from the same root cause and would lock the contract this PR is establishing.
Nit: // 4. dropCircularContainment comment block was renumbered but the inline doc on step 5 says "step 3 or step 4" — fine until someone renumbers again. Consider naming the steps instead of numbering them.
Problem
allIds, which is computed once by walkingchildrenB— the child set BEFORE step 3 removes orphan children and BEFORE step 5 removes containment-cycle nodes. So an edge whose endpoint is a node that gets dropped by step 3 or step 5 is NOT dropped: it survives in the returnededgesarray pointing at a node that no longer exists inchildren. Verified by reproducing the logic: input children[a, orphan(parentId:ghost)]with edgea -> orphan; after repairchildrenis[a](orphan dropped) yetedgesstill containse1: a -> orphan. This dangling edge is exactly the kind of malformed input that can makeelk.layout()throw, which then trips theelk-layout-failedfatal path the repair function exists to avoid.Fix
allIdsfrom childrenB. Concretely: rename the step-4allIdsuse to a freshly-walked set, e.g. const finalIds = new Set(); const walkFinal = (children: ElkChild[]) => { for (const c of children) { finalIds.add(c.id); if (c.children) walkFinal(c.children); } }; walkFinal(childrenD); let orphanEdges = 0; const…Testing
Adds unit test(s) that fail before the change and pass after. The full dashboard test suite,
eslint, andtsc --noEmitall pass locally on this branch.Found via a static correctness audit of the dashboard ELK layout repair.
🤖 Generated with Claude Code