feat: gather the category hierarchy rules in one module (#393)

src/utils/categoryHierarchy.ts holds the depth, height and descent walks
that buildParentOptions had inline, each with a set of the categories it
has seen, so a parent_id cycle ends the walk. A category caught in a cycle,
or under one, now has no depth instead of a made-up one, and the parent
list of the category form never offers it: a child placed there would be
hidden with it. The module also carries the orphan diagnostic, the parent
change refusal and the root rule that the service, the Categories page and
the budget use next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-18 19:16:14 -04:00
parent 14ebf0f37d
commit df3fefe934
5 changed files with 490 additions and 64 deletions

View file

@ -97,6 +97,13 @@ describe("buildParentOptions — editing a category", () => {
expect(offered.every((id) => id === 1 || id === 2)).toBe(true); 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", () => { 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 // 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. // unbounded). Only roots remain, so the root outside the cycle is offered.

View file

@ -1,7 +1,5 @@
import type { CategoryTreeNode } from "../../shared/types"; import type { CategoryTreeNode } from "../../shared/types";
import { depthsOf, fitsUnder, subtreeHeight } from "../../utils/categoryHierarchy";
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
const MAX_PARENT_DEPTH = 1;
export interface ParentOptionsContext { export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */ /** Id of the category being edited; absent or null when creating. */
@ -14,60 +12,9 @@ export interface ParentOptionsContext {
currentParentId?: number | null; currentParentId?: number | null;
} }
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
const byId = new Map<number, CategoryTreeNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
const depthOf = (id: number, seen: Set<number>): 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 * Categories that may be chosen as the parent in the category form, and in the
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the * « Vérifier la hiérarchie » window (#393).
* walk can never take more steps than there are categories.
*/
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
const childrenOf = new Map<number, number[]>();
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<number>([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.
* *
* - Roots and level-1 categories only: a level-2 parent would create a fourth * - 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. * 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 * whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category, * (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed. * 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. * - 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 * The depth and height rules are those of `src/utils/categoryHierarchy.ts`,
* given, without a depth or cycle check (#393 will add one). The form therefore * which `updateCategory` and `reattachCategory` apply again before writing
* never offers a new parent that the rules above reject. * (#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). * Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/ */
@ -95,15 +46,13 @@ export function buildParentOptions(
const depths = depthsOf(categories); const depths = depthsOf(categories);
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0; 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) => { return categories.filter((c) => {
if (c.id === editingId) return false; if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is // 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); // stored. Keeping it changes nothing on save (it is already the parent).
// repairing such data is the job of #393.
if (c.id === currentParentId) return true; 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);
}); });
} }

View file

@ -99,6 +99,19 @@ describe("sortHierarchical", () => {
expect(ordered).toEqual([1, 10, 500]); 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", () => { it("is stable (does not duplicate) when called with already-ordered input", () => {
const input: Category[] = [ const input: Category[] = [
cat(1, "Revenus", null, 1), cat(1, "Revenus", null, 1),

View file

@ -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");
});
});

View file

@ -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<number>): 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<number, number> {
const byId = new Map<number, HierarchyNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
/** Categories whose depth is known, or known to be none. */
const settled = new Set<number>();
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<number>();
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<number, number[]>();
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<number>([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<number> {
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<number, HierarchyRow>();
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<number>();
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<number>();
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);
}