diff --git a/src/components/categories/parentOptions.test.ts b/src/components/categories/parentOptions.test.ts index def71fa..eaaeb58 100644 --- a/src/components/categories/parentOptions.test.ts +++ b/src/components/categories/parentOptions.test.ts @@ -97,6 +97,13 @@ describe("buildParentOptions — editing a category", () => { expect(offered.every((id) => id === 1 || id === 2)).toBe(true); }); + it("never offers a member of a cycle, nor what hangs under it: a child there would be hidden (#393)", () => { + // 1 <-> 2 with 4 under 2; 3 and 5 are sound roots. Creating, then editing 5. + const cyclic = [node(1, 2), node(2, 1), node(4, 2), node(3, null), node(5, null)]; + expect(ids(buildParentOptions(cyclic))).toEqual([3, 5]); + expect(ids(buildParentOptions(cyclic, { editingId: 5 }))).toEqual([3]); + }); + it("terminates when the edited category is itself a member of a cycle", () => { // 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not // unbounded). Only roots remain, so the root outside the cycle is offered. diff --git a/src/components/categories/parentOptions.ts b/src/components/categories/parentOptions.ts index 04fd296..2b5da06 100644 --- a/src/components/categories/parentOptions.ts +++ b/src/components/categories/parentOptions.ts @@ -1,7 +1,5 @@ import type { CategoryTreeNode } from "../../shared/types"; - -/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */ -const MAX_PARENT_DEPTH = 1; +import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy"; export interface ParentOptionsContext { /** Id of the category being edited; absent or null when creating. */ @@ -14,60 +12,9 @@ export interface ParentOptionsContext { currentParentId?: number | null; } -function depthsOf(categories: CategoryTreeNode[]): Map { - const byId = new Map(); - for (const c of categories) byId.set(c.id, c); - const depths = new Map(); - const depthOf = (id: number, seen: Set): number => { - const known = depths.get(id); - if (known !== undefined) return known; - if (seen.has(id)) return 0; // cycle: treat as a root rather than loop - seen.add(id); - const cat = byId.get(id); - const parentId = cat?.parent_id ?? null; - // A missing parent (orphan) counts as a root, like the tree and the combobox do. - const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1; - depths.set(id, d); - return d; - }; - for (const c of categories) depthOf(c.id, new Set()); - return depths; -} - /** - * Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with - * grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the - * walk can never take more steps than there are categories. - */ -function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number { - const childrenOf = new Map(); - for (const c of categories) { - if (c.parent_id === null) continue; - const bucket = childrenOf.get(c.parent_id) ?? []; - bucket.push(c.id); - childrenOf.set(c.parent_id, bucket); - } - let height = 0; - const seen = new Set([rootId]); - let frontier = [rootId]; - while (frontier.length > 0 && height < categories.length) { - const next: number[] = []; - for (const id of frontier) { - for (const child of childrenOf.get(id) ?? []) { - if (seen.has(child)) continue; - seen.add(child); - next.push(child); - } - } - if (next.length === 0) break; - height += 1; - frontier = next; - } - return height; -} - -/** - * Categories that may be chosen as the parent in the category form. + * Categories that may be chosen as the parent in the category form, and in the + * « Vérifier la hiérarchie » window (#393). * * - Roots and level-1 categories only: a level-2 parent would create a fourth * level, which nothing else in the app knows how to display. @@ -78,11 +25,15 @@ function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number { * whole descent, which would create a cycle that hides the branch everywhere * (#393): a descendant sits at least one level below the edited category, * deeper than any depth still allowed. + * - A category caught in a `parent_id` cycle, or under one, has no depth and is + * never offered: a child placed under it would be hidden with it. * - The parent currently set on the form is always kept when it exists. * - * This list is the only guard: `updateCategory` writes any `parent_id` it is - * given, without a depth or cycle check (#393 will add one). The form therefore - * never offers a new parent that the rules above reject. + * The depth and height rules are those of `src/utils/categoryHierarchy.ts`, + * which `updateCategory` and `reattachCategory` apply again before writing + * (#393): the list only spares a refusal at save time. It does not leave out + * a parent that holds transactions; the service refuses it with a translated + * message. * * Ordering is left to the caller (`CategoryCombobox` sorts hierarchically). */ @@ -95,15 +46,13 @@ export function buildParentOptions( const depths = depthsOf(categories); const height = editingId !== null ? subtreeHeight(categories, editingId) : 0; - // Negative when the edited category has grandchildren: only the current parent survives. - const maxDepth = MAX_PARENT_DEPTH - height; return categories.filter((c) => { if (c.id === editingId) return false; // Kept even when the depth rule would drop it: the field must show what is - // stored. Keeping it changes nothing on save (it is already the parent); - // repairing such data is the job of #393. + // stored. Keeping it changes nothing on save (it is already the parent). if (c.id === currentParentId) return true; - return (depths.get(c.id) ?? 0) <= maxDepth; + const depth = depths.get(c.id); + return depth !== undefined && fitsUnder(depth, height); }); } diff --git a/src/components/shared/CategoryCombobox.test.ts b/src/components/shared/CategoryCombobox.test.ts index 854e52c..11cfa5b 100644 --- a/src/components/shared/CategoryCombobox.test.ts +++ b/src/components/shared/CategoryCombobox.test.ts @@ -99,6 +99,19 @@ describe("sortHierarchical", () => { expect(ordered).toEqual([1, 10, 500]); }); + it("leaves out the members of a parent_id cycle and what hangs under it, and ends (#393)", () => { + // 3 <-> 5, 8 under 5: no root leads to them, and their parents are in the + // list, so they are no orphans of it either. This is why a cycle hides a + // category from every picker; « Vérifier la hiérarchie » repairs it. + const input: Category[] = [ + cat(1, "Revenus", null, 1), + cat(3, "Épicerie", 5, 1), + cat(5, "Alimentation", 3, 1), + cat(8, "Boulangerie", 5, 2), + ]; + expect(sortHierarchical(input, displayName).map((c) => c.id)).toEqual([1]); + }); + it("is stable (does not duplicate) when called with already-ordered input", () => { const input: Category[] = [ cat(1, "Revenus", null, 1), diff --git a/src/utils/categoryHierarchy.test.ts b/src/utils/categoryHierarchy.test.ts new file mode 100644 index 0000000..de853a6 --- /dev/null +++ b/src/utils/categoryHierarchy.test.ts @@ -0,0 +1,220 @@ +// categoryHierarchy — the rules of the category hierarchy (#393): depths, +// heights and descent, the orphan diagnostic and the validation of a new +// parent. Every walk must end on a `parent_id` cycle: a test that loops runs +// into vitest's timeout and goes red. +import { describe, it, expect } from "vitest"; +import { + MAX_CATEGORY_DEPTH, + depthsOf, + descendantsOf, + displaysAsRoot, + findOrphans, + fitsUnder, + isActiveRow, + parentChangeRefusal, + subtreeHeight, + type HierarchyNode, + type HierarchyRow, +} from "./categoryHierarchy"; + +const n = (id: number, parent_id: number | null): HierarchyNode => ({ id, parent_id }); +/** An active row, or an inactive one with `active = false`. */ +const r = (id: number, parent_id: number | null, active = true): HierarchyRow => ({ + id, + parent_id, + is_active: active ? 1 : 0, +}); + +// Two roots. 1 -> 10 -> 100 and 1 -> 11; 2 -> 20. +const TREE = [n(1, null), n(10, 1), n(100, 10), n(11, 1), n(2, null), n(20, 2)]; + +const depthEntries = (nodes: HierarchyNode[]) => + [...depthsOf(nodes).entries()].sort(([a], [b]) => a - b); + +describe("depthsOf", () => { + it("gives 0 to a root, 1 to its child, 2 to a grandchild", () => { + expect(depthEntries(TREE)).toEqual([ + [1, 0], + [2, 0], + [10, 1], + [11, 1], + [20, 1], + [100, 2], + ]); + }); + + it("does not depend on the order of the list", () => { + expect(depthEntries([...TREE].reverse())).toEqual(depthEntries(TREE)); + }); + + it("treats a category whose parent is not in the list as a root", () => { + expect(depthEntries([n(10, 1), n(100, 10)])).toEqual([ + [10, 0], + [100, 1], + ]); + }); + + it("gives no depth to the members of a cycle nor to what hangs under it, and ends", () => { + // 1 <-> 2, 3 under 1, 4 under 3; 5 is a sound root. + const depths = depthsOf([n(3, 1), n(1, 2), n(2, 1), n(4, 3), n(5, null)]); + expect([...depths.entries()]).toEqual([[5, 0]]); + }); + + it("gives no depth to a category that is its own parent", () => { + expect(depthsOf([n(7, 7), n(8, 7)]).size).toBe(0); + }); + + it("ends on a three-member cycle reached from below", () => { + const depths = depthsOf([n(9, 3), n(3, 2), n(2, 1), n(1, 3)]); + expect(depths.size).toBe(0); + }); +}); + +describe("subtreeHeight and descendantsOf", () => { + it("count the levels and list the categories under a category", () => { + expect(subtreeHeight(TREE, 1)).toBe(2); + expect(subtreeHeight(TREE, 10)).toBe(1); + expect(subtreeHeight(TREE, 100)).toBe(0); + expect([...descendantsOf(TREE, 1)].sort((a, b) => a - b)).toEqual([10, 11, 100]); + expect(descendantsOf(TREE, 100).size).toBe(0); + }); + + it("end on a cycle, each category once, the start never listed", () => { + // 1 <-> 2, 3 under 2. + const cyclic = [n(1, 2), n(2, 1), n(3, 2)]; + expect(subtreeHeight(cyclic, 1)).toBe(2); + expect([...descendantsOf(cyclic, 1)].sort((a, b) => a - b)).toEqual([2, 3]); + expect(subtreeHeight([n(7, 7)], 7)).toBe(0); + expect(descendantsOf([n(7, 7)], 7).size).toBe(0); + }); +}); + +describe("fitsUnder", () => { + it("allows three levels, 0 to 2, counting the sub-tree", () => { + expect(MAX_CATEGORY_DEPTH).toBe(2); + expect(fitsUnder(0, 0)).toBe(true); + expect(fitsUnder(1, 0)).toBe(true); + expect(fitsUnder(0, 1)).toBe(true); + expect(fitsUnder(2, 0)).toBe(false); + expect(fitsUnder(1, 1)).toBe(false); + expect(fitsUnder(0, 2)).toBe(false); + }); +}); + +describe("displaysAsRoot", () => { + const ids = new Set([1, 2, 10]); + it("is a root without a parent, or with a parent missing from the list", () => { + expect(displaysAsRoot({ id: 1, parent_id: null }, ids)).toBe(true); + expect(displaysAsRoot({ id: 1 }, ids)).toBe(true); + expect(displaysAsRoot({ id: 30, parent_id: 99 }, ids)).toBe(true); + }); + + it("is not a root under a parent of the list, a cycle member included", () => { + expect(displaysAsRoot({ id: 10, parent_id: 1 }, ids)).toBe(false); + expect(displaysAsRoot({ id: 1, parent_id: 2 }, ids)).toBe(false); + }); +}); + +describe("isActiveRow", () => { + it("reads 1 (or true) as active, anything else as inactive, as `is_active = 1` does", () => { + expect(isActiveRow(r(1, null))).toBe(true); + expect(isActiveRow({ id: 1, parent_id: null, is_active: true })).toBe(true); + expect(isActiveRow(r(1, null, false))).toBe(false); + expect(isActiveRow({ id: 1, parent_id: null, is_active: false })).toBe(false); + }); +}); + +describe("findOrphans (#393)", () => { + it("finds nothing in a sound tree", () => { + expect(findOrphans(TREE.map((c) => r(c.id, c.parent_id ?? null)))).toEqual([]); + }); + + it("finds a category that is its own parent", () => { + expect(findOrphans([r(1, null), r(7, 7), r(8, 7)])).toEqual([{ id: 7, reason: "cycle" }]); + }); + + it("lists a two-member cycle once, by its smallest id, not what hangs under it", () => { + // 5 <-> 3, 8 under 5, 9 under 8. Read from below first. + const rows = [r(9, 8), r(8, 5), r(5, 3), r(3, 5), r(1, null)]; + expect(findOrphans(rows)).toEqual([{ id: 3, reason: "cycle" }]); + }); + + it("lists a three-member cycle once, by its smallest id, whatever the order of the rows", () => { + // 6 -> 4 -> 9 -> 6, 12 under 9. + const rows = [r(6, 4), r(4, 9), r(9, 6), r(12, 9), r(1, null)]; + expect(findOrphans(rows)).toEqual([{ id: 4, reason: "cycle" }]); + expect(findOrphans([...rows].reverse())).toEqual([{ id: 4, reason: "cycle" }]); + }); + + it("names a deactivated parent apart from a missing one, and only the head of each group", () => { + // 20's parent (2) is inactive; 21 under 20 follows it. 30's parent (99) does not exist. + const rows = [r(1, null), r(2, null, false), r(21, 20), r(20, 2), r(30, 99), r(31, 30)]; + expect(findOrphans(rows)).toEqual([ + { id: 20, reason: "inactive_parent" }, + { id: 30, reason: "missing_parent" }, + ]); + }); + + it("never lists an inactive category, nor a cycle among inactive ones", () => { + const rows = [r(1, null), r(2, 3, false), r(3, 2, false), r(4, 4, false)]; + expect(findOrphans(rows)).toEqual([]); + }); + + it("reports a loop that runs through a deactivated category as the deactivated parent", () => { + // 5 -> 6 (inactive) -> 5: the tree shows 5 at the root, its parent being inactive. + expect(findOrphans([r(5, 6), r(6, 5, false)])).toEqual([{ id: 5, reason: "inactive_parent" }]); + }); + + it("lists several groups, sorted by id", () => { + const rows = [r(40, 41), r(41, 40), r(15, 15), r(2, 77), r(1, null)]; + expect(findOrphans(rows)).toEqual([ + { id: 2, reason: "missing_parent" }, + { id: 15, reason: "cycle" }, + { id: 40, reason: "cycle" }, + ]); + }); +}); + +describe("parentChangeRefusal (#393)", () => { + it("accepts the root, always, even for a branch three levels deep or a cycle member", () => { + expect(parentChangeRefusal(TREE, 1, null)).toBeNull(); + expect(parentChangeRefusal([n(1, 2), n(2, 1)], 1, null)).toBeNull(); + }); + + it("refuses the category itself", () => { + expect(parentChangeRefusal(TREE, 11, 11)).toBe("self_parent"); + }); + + it("refuses a child and a grandchild: the move would close a loop", () => { + expect(parentChangeRefusal(TREE, 1, 10)).toBe("cycle"); + expect(parentChangeRefusal(TREE, 1, 100)).toBe("cycle"); + }); + + it("refuses a parent caught in another cycle: the category would be hidden with it", () => { + const nodes = [...TREE, n(50, 51), n(51, 50)]; + expect(parentChangeRefusal(nodes, 20, 50)).toBe("cycle"); + }); + + it("refuses a fourth level, counting the sub-tree of the category", () => { + // 100 is level 2: a child under it would be level 3. + expect(parentChangeRefusal(TREE, 20, 100)).toBe("too_deep"); + // Root 2 has a child (20): under 10 (level 1), 20 would land at level 3. + expect(parentChangeRefusal(TREE, 2, 10)).toBe("too_deep"); + // Root 1 has grandchildren: under any parent, 100 would land at level 3. + expect(parentChangeRefusal(TREE, 1, 2)).toBe("too_deep"); + }); + + it("accepts a valid move", () => { + expect(parentChangeRefusal(TREE, 20, 10)).toBeNull(); // a leaf under a level-1 parent + expect(parentChangeRefusal(TREE, 2, 1)).toBeNull(); // a root with one level under a root + expect(parentChangeRefusal(TREE, 11, 2)).toBeNull(); + }); + + it("moves a member of a cycle under a sound parent when the branch fits, and ends", () => { + // 1 <-> 2 with nothing under them; 3 is a sound root. + const nodes = [n(1, 2), n(2, 1), n(3, null)]; + expect(parentChangeRefusal(nodes, 1, 3)).toBeNull(); + // ... but never under the other member. + expect(parentChangeRefusal(nodes, 1, 2)).toBe("cycle"); + }); +}); diff --git a/src/utils/categoryHierarchy.ts b/src/utils/categoryHierarchy.ts new file mode 100644 index 0000000..c970aaf --- /dev/null +++ b/src/utils/categoryHierarchy.ts @@ -0,0 +1,237 @@ +// categoryHierarchy — the rules of the category hierarchy, in one place (#393). +// +// Three consumers read them: the parent list of the category form +// (`buildParentOptions`, #423), the orphan diagnostic of the Categories page +// (`findOrphans`) and the validation of a parent change in `updateCategory` +// and `reattachCategory` (`parentChangeRefusal`). Every walk, up a parent chain +// or down the children, keeps a set of the categories it has seen: a +// `parent_id` cycle stored in the profile ends the walk instead of looping. +// +// Levels: a root sits at depth 0, its children at 1, their children at 2. +// Three levels at most: the budget grid stops at the grandchildren, so a +// fourth level would drop out of it. + +/** What the hierarchy rules read of a category. */ +export interface HierarchyNode { + id: number; + /** `null` (or absent) for a root. */ + parent_id?: number | null; +} + +/** A row of `SELECT id, parent_id, is_active FROM categories`, active or not. */ +export interface HierarchyRow extends HierarchyNode { + /** `1` for an active category (SQLite's INTEGER flag). */ + is_active: number | boolean; +} + +/** Deepest depth a category may sit at: three levels, 0 to 2. */ +export const MAX_CATEGORY_DEPTH = 2; + +/** Whether the row is active, as the queries' `WHERE is_active = 1` reads it. */ +export function isActiveRow(row: HierarchyRow): boolean { + return row.is_active === 1 || row.is_active === true; +} + +/** + * Whether the category shows as a root among the categories `ids`: it has no + * parent, or a parent that is not in the list (deactivated, or gone). The + * tree, the category pickers and the budget all follow this rule (#393). + */ +export function displaysAsRoot(category: HierarchyNode, ids: ReadonlySet): boolean { + return category.parent_id == null || !ids.has(category.parent_id); +} + +/** + * Depth of each category among `categories`: 0 for a root, 1 for its child, 2 + * for a grandchild. A category whose parent is not in the list counts as a + * root, as in the tree. A category caught in a `parent_id` cycle, or under + * one, has no depth: no root leads to it, and it is left out of the map. + */ +export function depthsOf(categories: readonly HierarchyNode[]): Map { + const byId = new Map(); + for (const c of categories) byId.set(c.id, c); + const depths = new Map(); + /** Categories whose depth is known, or known to be none. */ + const settled = new Set(); + + for (const start of categories) { + if (settled.has(start.id)) continue; + // Walk up until a root, a settled category, or a category already seen on + // this walk: a cycle. + const path: number[] = []; + const seen = new Set(); + let node = start; + let topDepth: number | undefined; + for (;;) { + path.push(node.id); + seen.add(node.id); + const parent = node.parent_id == null ? undefined : byId.get(node.parent_id); + if (parent === undefined) { + topDepth = 0; + break; + } + if (settled.has(parent.id)) { + const above = depths.get(parent.id); + topDepth = above === undefined ? undefined : above + 1; + break; + } + if (seen.has(parent.id)) { + topDepth = undefined; + break; + } + node = parent; + } + // The last category of the path sits at `topDepth`, each one before it a + // level deeper; none has a depth when the walk ended in a cycle. + for (let i = path.length - 1, below = 0; i >= 0; i -= 1, below += 1) { + settled.add(path[i]); + if (topDepth !== undefined) depths.set(path[i], topDepth + below); + } + } + return depths; +} + +/** + * The categories under `rootId`, level by level. Each category is taken once, + * so a cycle ends the walk, and `rootId` itself is never listed. + */ +function levelsBelow(categories: readonly HierarchyNode[], rootId: number): number[][] { + const childrenOf = new Map(); + for (const c of categories) { + if (c.parent_id == null) continue; + const bucket = childrenOf.get(c.parent_id) ?? []; + bucket.push(c.id); + childrenOf.set(c.parent_id, bucket); + } + const levels: number[][] = []; + const seen = new Set([rootId]); + let frontier = [rootId]; + for (;;) { + const next: number[] = []; + for (const id of frontier) { + for (const child of childrenOf.get(id) ?? []) { + if (seen.has(child)) continue; + seen.add(child); + next.push(child); + } + } + if (next.length === 0) return levels; + levels.push(next); + frontier = next; + } +} + +/** Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with grandchildren. */ +export function subtreeHeight(categories: readonly HierarchyNode[], rootId: number): number { + return levelsBelow(categories, rootId).length; +} + +/** Every category under `rootId`, at any depth; never `rootId` itself, even inside a cycle. */ +export function descendantsOf(categories: readonly HierarchyNode[], rootId: number): Set { + return new Set(levelsBelow(categories, rootId).flat()); +} + +/** + * Whether a category whose sub-tree is `height` levels deep may sit under a + * parent at `parentDepth`: its deepest descendant must stay within the three + * levels. + */ +export function fitsUnder(parentDepth: number, height: number): boolean { + return parentDepth + 1 + height <= MAX_CATEGORY_DEPTH; +} + +/** Why a parent change is refused (the service adds `parent_has_transactions`). */ +export type HierarchyRefusal = "self_parent" | "cycle" | "too_deep"; + +/** + * Why category `id` may not move under `newParentId`, or `null` when it may. + * `active` are the active categories: depth and descent are counted on them, + * as the tree shows them. The caller has checked that `newParentId` is one of + * them. + * + * - The root (`null`) is always accepted: no place is shallower, and it is the + * repair of last resort for a category caught in a cycle. + * - `self_parent`: the category itself. + * - `cycle`: a category under it (the move would close a loop), or a category + * with no depth, caught in a cycle or under one (the move would hide it). + * - `too_deep`: its deepest descendant would land beyond the third level. + */ +export function parentChangeRefusal( + active: readonly HierarchyNode[], + id: number, + newParentId: number | null, +): HierarchyRefusal | null { + if (newParentId === null) return null; + if (newParentId === id) return "self_parent"; + if (descendantsOf(active, id).has(newParentId)) return "cycle"; + const parentDepth = depthsOf(active).get(newParentId); + if (parentDepth === undefined) return "cycle"; + if (!fitsUnder(parentDepth, subtreeHeight(active, id))) return "too_deep"; + return null; +} + +/** Why an active category is out of the hierarchy (#393). */ +export type OrphanReason = "cycle" | "inactive_parent" | "missing_parent"; + +export interface OrphanCategory { + /** The category to reattach; the categories under it follow it. */ + id: number; + reason: OrphanReason; +} + +/** + * The active categories the tree cannot place, one per group to reattach + * (#393), sorted by id: + * - `inactive_parent`, `missing_parent`: an active category whose parent is + * deactivated, or gone. The tree shows it as a root, out of its place. + * - `cycle`: for each `parent_id` cycle among active categories, its member + * with the smallest id. The cycle and everything under it are out of reach + * from the roots: hidden from the tree, the pickers, the budget and the + * reports. Reattaching that member breaks the cycle; the other members and + * their descendants come back under it. + * Only the head of each group is listed: a category under it follows it. + * + * `rows` are all the categories, inactive ones included: without them, a + * deactivated parent could not be told from a missing one. + */ +export function findOrphans(rows: readonly HierarchyRow[]): OrphanCategory[] { + const byId = new Map(); + for (const r of rows) byId.set(r.id, r); + const orphans: OrphanCategory[] = []; + /** Active categories whose group is known: visible, an orphan, or under one. */ + const settled = new Set(); + + for (const start of rows) { + if (!isActiveRow(start) || settled.has(start.id)) continue; + // Walk up the active parents until a root, a parent that is inactive or + // gone, a settled category, or a category already seen on this walk. + const path: number[] = []; + const seen = new Set(); + let node = start; + for (;;) { + path.push(node.id); + seen.add(node.id); + if (node.parent_id == null) break; + const parent = byId.get(node.parent_id); + if (parent === undefined) { + orphans.push({ id: node.id, reason: "missing_parent" }); + break; + } + if (!isActiveRow(parent)) { + orphans.push({ id: node.id, reason: "inactive_parent" }); + break; + } + if (settled.has(parent.id)) break; + if (seen.has(parent.id)) { + // The cycle runs from the parent to the end of the path; what comes + // before it on the path hangs under the cycle. + const members = path.slice(path.indexOf(parent.id)); + orphans.push({ id: Math.min(...members), reason: "cycle" }); + break; + } + node = parent; + } + for (const id of path) settled.add(id); + } + return orphans.sort((a, b) => a.id - b.id); +}