feat(graph): implement query methods — topologicalOrder, hasCycles, findCycles, ancestors, descendants, reachableFrom, and call graph queries (filterByStatus, getRoots, children, duration, lineage)

This commit is contained in:
2026-05-21 21:15:58 +00:00
parent d63ef886d8
commit 750ef2d4b7
4 changed files with 695 additions and 7 deletions

View File

@@ -1,7 +1,441 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import { FlowGraph } from "../../src/graph/construction.js";
import {
topologicalOrder,
hasCycles,
findCycles,
ancestors,
descendants,
reachableFrom,
} from "../../src/graph/queries.js";
import { CycleError, NodeNotFoundError } from "../../src/error/index.js";
import type { DirectedGraph } from "graphology";
describe('graph queries', () => {
it('placeholder', () => {
expect(true).toBe(true);
function buildDiamondDag(): FlowGraph {
const fg = new FlowGraph();
fg.addNode("top", { name: "top" });
fg.addNode("left", { name: "left" });
fg.addNode("right", { name: "right" });
fg.addNode("bottom", { name: "bottom" });
fg.addEdge("top", "left");
fg.addEdge("top", "right");
fg.addEdge("left", "bottom");
fg.addEdge("right", "bottom");
return fg;
}
function buildChainDag(): FlowGraph {
const fg = new FlowGraph();
fg.addNode("a", { name: "a" });
fg.addNode("b", { name: "b" });
fg.addNode("c", { name: "c" });
fg.addNode("d", { name: "d" });
fg.addEdge("a", "b");
fg.addEdge("b", "c");
fg.addEdge("c", "d");
return fg;
}
describe("FlowGraph topologicalOrder", () => {
it("returns topological order for a chain", () => {
const fg = buildChainDag();
const order = fg.topologicalOrder();
expect(order).toEqual(["a", "b", "c", "d"]);
});
it("returns topological order for a diamond DAG", () => {
const fg = buildDiamondDag();
const order = fg.topologicalOrder();
expect(order[0]).toBe("top");
expect(order[3]).toBe("bottom");
expect(order).toContain("left");
expect(order).toContain("right");
});
it("returns empty array for empty graph", () => {
const fg = new FlowGraph();
expect(fg.topologicalOrder()).toEqual([]);
});
it("returns single node for graph with one node", () => {
const fg = new FlowGraph();
fg.addNode("only", { name: "only" });
expect(fg.topologicalOrder()).toEqual(["only"]);
});
it("throws CycleError when graph has cycles (via raw graph access)", () => {
const fg = new FlowGraph();
fg.addNode("a", { name: "a" });
fg.addNode("b", { name: "b" });
fg._edgeKey;
fg.graph.addNode("c");
fg.graph.addEdgeWithKey("a->b", "a", "b");
fg.graph.addEdgeWithKey("b->c", "b", "c");
fg.graph.addEdgeWithKey("c->a", "c", "a");
expect(() => fg.topologicalOrder()).toThrow(CycleError);
});
});
describe("FlowGraph hasCycles", () => {
it("returns false for a DAG", () => {
const fg = buildDiamondDag();
expect(fg.hasCycles()).toBe(false);
});
it("returns false for empty graph", () => {
const fg = new FlowGraph();
expect(fg.hasCycles()).toBe(false);
});
it("returns false for a chain", () => {
const fg = buildChainDag();
expect(fg.hasCycles()).toBe(false);
});
it("returns true when graph has cycles (via raw graph)", () => {
const fg = new FlowGraph();
fg.graph.addNode("a");
fg.graph.addNode("b");
fg.graph.addEdgeWithKey("a->b", "a", "b");
fg.graph.addEdgeWithKey("b->a", "b", "a");
expect(fg.hasCycles()).toBe(true);
});
});
describe("FlowGraph findCycles", () => {
it("returns empty array for a DAG", () => {
const fg = buildDiamondDag();
expect(fg.findCycles()).toEqual([]);
});
it("returns empty array for empty graph", () => {
const fg = new FlowGraph();
expect(fg.findCycles()).toEqual([]);
});
it("finds a simple cycle (via raw graph)", () => {
const fg = new FlowGraph();
fg.graph.addNode("a");
fg.graph.addNode("b");
fg.graph.addEdgeWithKey("a->b", "a", "b");
fg.graph.addEdgeWithKey("b->a", "b", "a");
const cycles = fg.findCycles();
expect(cycles.length).toBeGreaterThan(0);
expect(cycles[0]!.length).toBeGreaterThan(0);
});
it("finds a three-node cycle (via raw graph)", () => {
const fg = new FlowGraph();
fg.graph.addNode("a");
fg.graph.addNode("b");
fg.graph.addNode("c");
fg.graph.addEdgeWithKey("a->b", "a", "b");
fg.graph.addEdgeWithKey("b->c", "b", "c");
fg.graph.addEdgeWithKey("c->a", "c", "a");
const cycles = fg.findCycles();
expect(cycles.length).toBeGreaterThan(0);
});
});
describe("FlowGraph ancestors", () => {
it("returns all ancestors for a node in a chain", () => {
const fg = buildChainDag();
expect(fg.ancestors("d")).toEqual(["c", "b", "a"]);
});
it("returns all ancestors for a node in a diamond DAG", () => {
const fg = buildDiamondDag();
const anc = fg.ancestors("bottom");
expect(anc).toContain("left");
expect(anc).toContain("right");
expect(anc).toContain("top");
expect(anc.length).toBe(3);
});
it("returns empty array for a root node", () => {
const fg = buildChainDag();
expect(fg.ancestors("a")).toEqual([]);
});
it("returns empty array for a node in a graph with no edges", () => {
const fg = new FlowGraph();
fg.addNode("a", { name: "a" });
fg.addNode("b", { name: "b" });
expect(fg.ancestors("a")).toEqual([]);
});
it("throws NodeNotFoundError for missing node", () => {
const fg = new FlowGraph();
expect(() => fg.ancestors("missing")).toThrow(NodeNotFoundError);
});
});
describe("FlowGraph descendants", () => {
it("returns all descendants for a node in a chain", () => {
const fg = buildChainDag();
expect(fg.descendants("a")).toEqual(["b", "c", "d"]);
});
it("returns all descendants for a node in a diamond DAG", () => {
const fg = buildDiamondDag();
const desc = fg.descendants("top");
expect(desc).toContain("left");
expect(desc).toContain("right");
expect(desc).toContain("bottom");
expect(desc.length).toBe(3);
});
it("returns only direct and transitive children from left in diamond", () => {
const fg = buildDiamondDag();
const desc = fg.descendants("left");
expect(desc).toEqual(["bottom"]);
});
it("returns empty array for a leaf node", () => {
const fg = buildChainDag();
expect(fg.descendants("d")).toEqual([]);
});
it("throws NodeNotFoundError for missing node", () => {
const fg = new FlowGraph();
expect(() => fg.descendants("missing")).toThrow(NodeNotFoundError);
});
});
describe("FlowGraph reachableFrom", () => {
it("returns all reachable nodes from a single start node", () => {
const fg = buildChainDag();
const reachable = fg.reachableFrom(["a"]);
expect(reachable).toEqual(new Set(["a", "b", "c", "d"]));
});
it("returns only the start node if it's a leaf", () => {
const fg = buildChainDag();
const reachable = fg.reachableFrom(["d"]);
expect(reachable).toEqual(new Set(["d"]));
});
it("returns union of reachable nodes from multiple start nodes", () => {
const fg = buildDiamondDag();
const reachable = fg.reachableFrom(["left", "right"]);
expect(reachable).toEqual(new Set(["left", "right", "bottom"]));
});
it("returns empty set for empty input", () => {
const fg = buildChainDag();
expect(fg.reachableFrom([])).toEqual(new Set());
});
it("skips nodes that don't exist in graph", () => {
const fg = buildChainDag();
const reachable = fg.reachableFrom(["a", "missing"]);
expect(reachable).toEqual(new Set(["a", "b", "c", "d"]));
});
});
describe("FlowGraph filterByStatus", () => {
it("returns node keys with matching status", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed" });
fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running" });
expect(fg.filterByStatus("running").sort()).toEqual(["r1", "r3"]);
expect(fg.filterByStatus("completed")).toEqual(["r2"]);
});
it("returns empty array when no nodes match", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" });
expect(fg.filterByStatus("failed")).toEqual([]);
});
it("returns empty array for empty graph", () => {
const fg = new FlowGraph();
expect(fg.filterByStatus("pending")).toEqual([]);
});
});
describe("FlowGraph getRoots", () => {
it("returns top-level call nodes", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending", parentRequestId: "r1" });
fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "completed" });
const roots = fg.getRoots().sort();
expect(roots).toEqual(["r1", "r3"]);
});
it("returns all nodes if none have parentRequestId", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" });
const roots = fg.getRoots().sort();
expect(roots).toEqual(["r1", "r2"]);
});
it("returns empty array for empty graph", () => {
const fg = new FlowGraph();
expect(fg.getRoots()).toEqual([]);
});
});
describe("FlowGraph children", () => {
it("returns direct children via triggered edges", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" });
fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "pending" });
fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "triggered" });
fg.graph.addEdgeWithKey("r1->r3", "r1", "r3", { edgeType: "triggered" });
const children = fg.children("r1").sort();
expect(children).toEqual(["r2", "r3"]);
});
it("excludes non-triggered edges", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "pending" });
fg.graph.addEdgeWithKey("r1->r2", "r1", "r2", { edgeType: "depends_on" });
expect(fg.children("r1")).toEqual([]);
});
it("returns empty array for a leaf node", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "pending" });
expect(fg.children("r1")).toEqual([]);
});
it("throws NodeNotFoundError for missing node", () => {
const fg = new FlowGraph();
expect(() => fg.children("missing")).toThrow(NodeNotFoundError);
});
});
describe("FlowGraph duration", () => {
it("returns duration in milliseconds", () => {
const fg = new FlowGraph();
fg.addNode("r1", {
requestId: "r1",
operationId: "op1",
status: "completed",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:05.000Z",
});
expect(fg.duration("r1")).toBe(5000);
});
it("returns 0 for simultaneous start and end", () => {
const fg = new FlowGraph();
fg.addNode("r1", {
requestId: "r1",
operationId: "op1",
status: "completed",
startedAt: "2026-01-01T00:00:00.000Z",
completedAt: "2026-01-01T00:00:00.000Z",
});
expect(fg.duration("r1")).toBe(0);
});
it("throws if startedAt is missing", () => {
const fg = new FlowGraph();
fg.addNode("r1", {
requestId: "r1",
operationId: "op1",
status: "running",
completedAt: "2026-01-01T00:00:05.000Z",
});
expect(() => fg.duration("r1")).toThrow();
});
it("throws if completedAt is missing", () => {
const fg = new FlowGraph();
fg.addNode("r1", {
requestId: "r1",
operationId: "op1",
status: "running",
startedAt: "2026-01-01T00:00:00.000Z",
});
expect(() => fg.duration("r1")).toThrow();
});
it("throws NodeNotFoundError for missing node", () => {
const fg = new FlowGraph();
expect(() => fg.duration("missing")).toThrow(NodeNotFoundError);
});
});
describe("FlowGraph lineage", () => {
it("returns ancestor chain from root to node", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" });
fg.addNode("r2", { requestId: "r2", operationId: "op2", status: "completed", parentRequestId: "r1" });
fg.addNode("r3", { requestId: "r3", operationId: "op3", status: "running", parentRequestId: "r2" });
expect(fg.lineage("r3")).toEqual(["r1", "r2", "r3"]);
});
it("returns single element for root node", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "completed" });
expect(fg.lineage("r1")).toEqual(["r1"]);
});
it("stops if parentRequestId node does not exist in graph", () => {
const fg = new FlowGraph();
fg.addNode("r1", { requestId: "r1", operationId: "op1", status: "running", parentRequestId: "r0" });
expect(fg.lineage("r1")).toEqual(["r1"]);
});
it("throws NodeNotFoundError for missing node", () => {
const fg = new FlowGraph();
expect(() => fg.lineage("missing")).toThrow(NodeNotFoundError);
});
});
describe("standalone topologicalOrder", () => {
it("returns topological order for a graph", () => {
const fg = buildChainDag();
expect(topologicalOrder(fg.graph)).toEqual(["a", "b", "c", "d"]);
});
});
describe("standalone hasCycles", () => {
it("returns false for DAG", () => {
const fg = buildChainDag();
expect(hasCycles(fg.graph)).toBe(false);
});
});
describe("standalone findCycles", () => {
it("returns empty for DAG", () => {
const fg = buildChainDag();
expect(findCycles(fg.graph)).toEqual([]);
});
});
describe("standalone ancestors", () => {
it("returns ancestors for a node", () => {
const fg = buildChainDag();
expect(ancestors(fg.graph, "d")).toEqual(["c", "b", "a"]);
});
});
describe("standalone descendants", () => {
it("returns descendants for a node", () => {
const fg = buildChainDag();
expect(descendants(fg.graph, "a")).toEqual(["b", "c", "d"]);
});
});
describe("standalone reachableFrom", () => {
it("returns all reachable nodes", () => {
const fg = buildDiamondDag();
const result = reachableFrom(fg.graph, ["top"]);
expect(result).toEqual(new Set(["top", "left", "right", "bottom"]));
});
it("returns union from multiple starts", () => {
const fg = buildDiamondDag();
const result = reachableFrom(fg.graph, ["left", "right"]);
expect(result).toEqual(new Set(["left", "right", "bottom"]));
});
});