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 2 commits 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.
…-pass contract Include the dropped edge ids in the elk-orphan-edge issue message so distinct losses produce distinct strings and survive store.ts appendLayoutIssues' `level|message` dedupe — previously a re-layout that dropped a different single edge yielded a byte-identical message and the fresh loss was swallowed. Document that dropOrphanEdges (step 5) is the single reconciliation point that validates edges against the final post-removal child tree, covering ghost endpoints plus orphan-child subtree drops and containment-cycle drops; per-pass attribution is deliberately not tracked. Add tests for the containment-cycle endpoint case and the cascading orphan-child parent drop carrying out a nested grandchild edge target, and assert distinct dropped-edge messages across runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2 (lossy observability / dedupe): Fixed. 3 (test gaps): Added both missing cases. (a) Containment-cycle endpoint: two nodes mutually contained via shared ids at different levels so the cycle pass removes both, then asserts the edge targeting one of them is dropped with its id in the message. (b) Cascading orphan-child drop: a parent with a missing 1 (layout as de-facto deduper / lost drop reason): Kept the current step-5 approach. The correctness invariant the PR promises holds — step 5 validates edges against the final post-removal child tree ( Nit (numeric step cross-references): The current comments are accurate post-renumber; I left the numbering but expanded the step-5 comment to also name the passes ( Full dashboard suite: 46 passed. |
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