fix(site-builder): repair cyclic orphan clusters in repairOrphanNodes
Review found that a cluster of orphan nodes referencing only each other (no member's parent points outside the orphan set) made the reattach loop find zero tops and silently no-op, leaving the cluster unreachable while reporting repaired: []. Replaced the single-pass reattach with a loop that re-derives the unreachable set each round and force-reattaches one representative when no ordinary top exists, guaranteeing findUnreachableNodeIds is empty after repair. Adds 2-node/3-node cycle and mixed ordinary-subtree-plus-cycle tests; the original 11 tests are unchanged and still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,4 +103,53 @@ describe('repairOrphanNodes', () => {
|
||||
expect(out.state).toBe(orphanOnly);
|
||||
expect(out.repaired).toEqual([]);
|
||||
});
|
||||
|
||||
// A cluster of orphans whose `parent` fields (and child lists) reference
|
||||
// only each other has no member pointing "outside" the cluster, so the
|
||||
// simple "reattach the top" rule finds no top at all. Regression coverage
|
||||
// for the invariant: after repair, nothing in the returned state may still
|
||||
// be unreachable -- see the loop comment in the implementation.
|
||||
|
||||
test('a 2-node orphan cycle is fully reachable after repair', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a 3-node orphan cycle is fully reachable after repair', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.p = node({ parent: 'r', nodes: ['q'] });
|
||||
nodes.q = node({ parent: 'p', nodes: ['r'] });
|
||||
nodes.r = node({ parent: 'q', nodes: ['p'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an ordinary orphan subtree and a separate orphan cycle in the same document are both repaired', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||
nodes.strayChild = node({ parent: 'stray' });
|
||||
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
// The ordinary subtree keeps its established "only the top is
|
||||
// reattached" behaviour: stray is reparented, strayChild rides along
|
||||
// untouched.
|
||||
expect(out.repaired).toContain('stray');
|
||||
expect(parsed.strayChild.parent).toBe('stray');
|
||||
// Exactly one representative per component is force-reattached: stray
|
||||
// (found via the normal rule) plus one of x/y (via the cycle fallback).
|
||||
expect(out.repaired.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user