From 1503ca07aa7b9a1841fcd5504990a6019d8a123b Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:11:12 +0000 Subject: [PATCH] feat(graph): implement FlowGraph class wrapping graphology DirectedGraph Implements the core FlowGraph class with generic type parameters, DAG-enforced mutations, cycle detection via graphology-dag, node/edge CRUD, traversal methods, and static factory stubs. --- src/graph/construction.ts | 200 ++++++++++++++++- src/graph/index.ts | 2 +- src/index.ts | 4 +- test/graph/construction.test.ts | 372 +++++++++++++++++++++++++++++++- 4 files changed, 571 insertions(+), 7 deletions(-) diff --git a/src/graph/construction.ts b/src/graph/construction.ts index 8cec2e9..c00cbad 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1 +1,199 @@ -export {}; \ No newline at end of file +import { DirectedGraph } from "graphology"; +import type { TSchema, Static } from "@alkdev/typebox"; +import { willCreateCycle } from "graphology-dag"; +import { + DuplicateNodeError, + DuplicateEdgeError, + NodeNotFoundError, + CycleError, +} from "../error/index.js"; + +export interface FlowGraphOptions { + type?: "directed"; + multi?: false; + allowSelfLoops?: false; +} + +type Attrs = Record; + +export class FlowGraph< + NodeAttrs extends TSchema = TSchema, + EdgeAttrs extends TSchema = TSchema, +> { + private _graph: DirectedGraph; + + constructor(_options?: FlowGraphOptions) { + this._graph = new DirectedGraph({ + type: "directed", + multi: false, + allowSelfLoops: false, + }); + } + + get graph(): DirectedGraph { + return this._graph; + } + + _edgeKey(source: string, target: string): string { + return `${source}->${target}`; + } + + addNode(key: string, attrs: Static): void { + if (this._graph.hasNode(key)) { + throw new DuplicateNodeError(key); + } + this._graph.addNode(key, attrs as Attrs); + } + + removeNode(key: string): void { + if (!this._graph.hasNode(key)) { + throw new NodeNotFoundError(key); + } + this._graph.dropNode(key); + } + + updateNode(key: string, attrs: Partial>): void { + if (!this._graph.hasNode(key)) { + throw new NodeNotFoundError(key); + } + this._graph.mergeNodeAttributes(key, attrs as Attrs); + } + + hasNode(key: string): boolean { + return this._graph.hasNode(key); + } + + getNodeAttributes(key: string): Static { + if (!this._graph.hasNode(key)) { + throw new NodeNotFoundError(key); + } + return this._graph.getNodeAttributes(key) as Static; + } + + addEdge(source: string, target: string, attrs?: Static): void { + if (!this._graph.hasNode(source)) { + throw new NodeNotFoundError(source); + } + if (!this._graph.hasNode(target)) { + throw new NodeNotFoundError(target); + } + if (this._graph.hasDirectedEdge(source, target)) { + throw new DuplicateEdgeError(source, target); + } + if (willCreateCycle(this._graph, source, target)) { + const path = this._findPath(target, source); + const cycle = [source, ...path, source]; + throw new CycleError([cycle]); + } + const key = this._edgeKey(source, target); + this._graph.addEdgeWithKey(key, source, target, (attrs ?? {}) as Attrs); + } + + removeEdge(source: string, target: string): void { + if (this._graph.hasDirectedEdge(source, target)) { + this._graph.dropDirectedEdge(source, target); + } + } + + hasEdge(source: string, target: string): boolean { + return this._graph.hasDirectedEdge(source, target); + } + + getEdgeAttributes(source: string, target: string): Static { + if (!this._graph.hasDirectedEdge(source, target)) { + throw new Error( + `Edge "${this._edgeKey(source, target)}" not found`, + ); + } + const edgeKey = this._graph.edge(source, target); + return this._graph.getEdgeAttributes(edgeKey) as Static; + } + + nodes(): string[] { + return this._graph.nodes(); + } + + edges(): string[] { + return this._graph.edges(); + } + + get order(): number { + return this._graph.order; + } + + get size(): number { + return this._graph.size; + } + + forEachNode( + callback: (key: string, attrs: Static) => void, + ): void { + this._graph.forEachNode((key, attrs) => { + callback(key, attrs as Static); + }); + } + + forEachEdge( + callback: ( + key: string, + attrs: Static, + source: string, + target: string, + ) => void, + ): void { + this._graph.forEachEdge((key, attrs, source, target) => { + callback(key, attrs as Static, source!, target!); + }); + } + + predecessors(nodeId: string): string[] { + return this._graph.inNeighbors(nodeId) ?? []; + } + + successors(nodeId: string): string[] { + return this._graph.outNeighbors(nodeId) ?? []; + } + + static fromSpecs( + _specs: unknown[], + ): FlowGraph { + throw new Error("not implemented"); + } + + static fromCallEvents( + _events: unknown[], + ): FlowGraph { + throw new Error("not implemented"); + } + + static fromJSON( + _data: unknown, + ): FlowGraph { + throw new Error("not implemented"); + } + + private _findPath(from: string, to: string): string[] { + const visited = new Set(); + const queue: Array<{ node: string; path: string[] }> = [ + { node: from, path: [from] }, + ]; + visited.add(from); + while (queue.length > 0) { + const current = queue.shift()!; + if (current.node === to) { + return current.path; + } + const neighbors = this._graph.outNeighbors(current.node) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push({ + node: neighbor, + path: [...current.path, neighbor], + }); + } + } + } + return []; + } +} \ No newline at end of file diff --git a/src/graph/index.ts b/src/graph/index.ts index 8cec2e9..86cd5b9 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1 +1 @@ -export {}; \ No newline at end of file +export { FlowGraph, type FlowGraphOptions } from "./construction.js"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b9b83cd..8f46bb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ -export * from "./error/index.js"; \ No newline at end of file +export * from "./error/index.js"; + +export { FlowGraph, type FlowGraphOptions } from "./graph/index.js"; \ No newline at end of file diff --git a/test/graph/construction.test.ts b/test/graph/construction.test.ts index 4c25711..ee84256 100644 --- a/test/graph/construction.test.ts +++ b/test/graph/construction.test.ts @@ -1,7 +1,371 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { FlowGraph } from "../../src/graph/construction.js"; +import { + DuplicateNodeError, + DuplicateEdgeError, + NodeNotFoundError, + CycleError, +} from "../../src/error/index.js"; -describe('graph construction', () => { - it('placeholder', () => { - expect(true).toBe(true); +describe("FlowGraph constructor", () => { + it("creates an empty graph", () => { + const fg = new FlowGraph(); + expect(fg.order).toBe(0); + expect(fg.size).toBe(0); + expect(fg.nodes()).toEqual([]); + expect(fg.edges()).toEqual([]); + }); + + it("exposes the underlying graphology instance via graph getter", () => { + const fg = new FlowGraph(); + expect(fg.graph).toBeDefined(); + expect(fg.graph.order).toBe(0); + }); + + it("accepts FlowGraphOptions", () => { + const fg = new FlowGraph({ type: "directed", multi: false, allowSelfLoops: false }); + expect(fg.order).toBe(0); + }); +}); + +describe("FlowGraph._edgeKey", () => { + it("produces deterministic keys", () => { + const fg = new FlowGraph(); + expect(fg._edgeKey("a", "b")).toBe("a->b"); + expect(fg._edgeKey("task.classify", "task.enrich")).toBe("task.classify->task.enrich"); + }); +}); + +describe("FlowGraph node operations", () => { + it("addNode adds a node with attributes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + expect(fg.hasNode("a")).toBe(true); + expect(fg.getNodeAttributes("a")).toEqual({ name: "a" }); + expect(fg.order).toBe(1); + }); + + it("addNode throws DuplicateNodeError on duplicate", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + expect(() => fg.addNode("a", { name: "a2" })).toThrow(DuplicateNodeError); + expect(() => fg.addNode("a", { name: "a2" })).toThrow('Node with key "a" already exists'); + }); + + it("removeNode removes a node and its edges", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + fg.removeNode("a"); + expect(fg.hasNode("a")).toBe(false); + expect(fg.hasEdge("a", "b")).toBe(false); + expect(fg.size).toBe(0); + }); + + it("removeNode throws NodeNotFoundError if missing", () => { + const fg = new FlowGraph(); + expect(() => fg.removeNode("missing")).toThrow(NodeNotFoundError); + }); + + it("updateNode partially merges attributes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a", version: "1.0" }); + fg.updateNode("a", { version: "2.0" }); + expect(fg.getNodeAttributes("a")).toEqual({ name: "a", version: "2.0" }); + }); + + it("updateNode throws NodeNotFoundError if missing", () => { + const fg = new FlowGraph(); + expect(() => fg.updateNode("missing", { x: 1 })).toThrow(NodeNotFoundError); + }); + + it("hasNode returns correct boolean", () => { + const fg = new FlowGraph(); + expect(fg.hasNode("a")).toBe(false); + fg.addNode("a", { name: "a" }); + expect(fg.hasNode("a")).toBe(true); + }); + + it("getNodeAttributes returns attributes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a", x: 42 }); + expect(fg.getNodeAttributes("a")).toEqual({ name: "a", x: 42 }); + }); + + it("getNodeAttributes throws NodeNotFoundError if missing", () => { + const fg = new FlowGraph(); + expect(() => fg.getNodeAttributes("missing")).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph edge operations", () => { + it("addEdge adds a directed edge with attributes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b", { edgeType: "typed" }); + expect(fg.hasEdge("a", "b")).toBe(true); + expect(fg.size).toBe(1); + }); + + it("addEdge creates deterministic edge key", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + expect(fg.edges()).toEqual(["a->b"]); + }); + + it("addEdge throws NodeNotFoundError for missing source", () => { + const fg = new FlowGraph(); + fg.addNode("b", { name: "b" }); + expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError); + }); + + it("addEdge throws NodeNotFoundError for missing target", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError); + }); + + it("addEdge throws DuplicateEdgeError if edge exists", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + expect(() => fg.addEdge("a", "b")).toThrow(DuplicateEdgeError); + }); + + it("addEdge throws CycleError if edge creates cycle", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "b"); + fg.addEdge("b", "c"); + expect(() => fg.addEdge("c", "a")).toThrow(CycleError); + }); + + it("CycleError includes cycle path", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + try { + fg.addEdge("b", "a"); + expect.unreachable("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(CycleError); + const ce = e as CycleError; + expect(ce.cycles.length).toBeGreaterThan(0); + expect(ce.cycles[0]![0]).toBe("b"); + expect(ce.cycles[0]!.at(-1)).toBe("b"); + } + }); + + it("addEdge allows non-cycle edges in DAG", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "b"); + fg.addEdge("a", "c"); + fg.addEdge("b", "c"); + expect(fg.size).toBe(3); + }); + + it("removeEdge removes an existing edge", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + fg.removeEdge("a", "b"); + expect(fg.hasEdge("a", "b")).toBe(false); + expect(fg.size).toBe(0); + }); + + it("removeEdge is a no-op if edge doesn't exist", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(() => fg.removeEdge("a", "b")).not.toThrow(); + }); + + it("hasEdge returns correct boolean", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(fg.hasEdge("a", "b")).toBe(false); + fg.addEdge("a", "b"); + expect(fg.hasEdge("a", "b")).toBe(true); + }); + + it("getEdgeAttributes returns edge attributes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b", { edgeType: "typed", compatible: true }); + const attrs = fg.getEdgeAttributes("a", "b"); + expect(attrs.edgeType).toBe("typed"); + expect(attrs.compatible).toBe(true); + }); + + it("getEdgeAttributes throws if edge doesn't exist", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(() => fg.getEdgeAttributes("a", "b")).toThrow(); + }); +}); + +describe("FlowGraph query methods", () => { + it("nodes returns all node keys", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(fg.nodes()).toEqual(["a", "b"]); + }); + + it("edges returns all edge keys", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + expect(fg.edges()).toEqual(["a->b"]); + }); + + it("order returns node count", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + expect(fg.order).toBe(2); + }); + + it("size returns edge count", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + expect(fg.size).toBe(1); + }); + + it("forEachNode iterates over all nodes", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + const collected: Array<{ key: string; attrs: Record }> = []; + fg.forEachNode((key, attrs) => collected.push({ key, attrs })); + expect(collected.length).toBe(2); + expect(collected[0]!.key).toBe("a"); + expect(collected[1]!.key).toBe("b"); + }); + + it("forEachEdge iterates over all edges", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b", { edgeType: "typed" }); + const collected: Array<{ key: string; source: string; target: string }> = []; + fg.forEachEdge((key, _attrs, source, target) => + collected.push({ key, source, target }), + ); + expect(collected.length).toBe(1); + expect(collected[0]!.key).toBe("a->b"); + expect(collected[0]!.source).toBe("a"); + expect(collected[0]!.target).toBe("b"); + }); + + it("predecessors returns in-neighbors", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "c"); + fg.addEdge("b", "c"); + expect(fg.predecessors("c")).toEqual(["a", "b"]); + }); + + it("successors returns out-neighbors", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "b"); + fg.addEdge("a", "c"); + expect(fg.successors("a")).toEqual(["b", "c"]); + }); +}); + +describe("FlowGraph static stubs", () => { + it("fromSpecs throws not implemented", () => { + expect(() => FlowGraph.fromSpecs([])).toThrow("not implemented"); + }); + + it("fromCallEvents throws not implemented", () => { + expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented"); + }); + + it("fromJSON throws not implemented", () => { + expect(() => FlowGraph.fromJSON({})).toThrow("not implemented"); + }); +}); + +describe("FlowGraph re-exports", () => { + it("is re-exported from src/graph/index.ts", async () => { + const mod = await import("../../src/graph/index.js"); + expect(mod.FlowGraph).toBeDefined(); + expect(mod.FlowGraphOptions).toBeUndefined(); + }); + + it("is re-exported from src/index.ts", async () => { + const mod = await import("../../src/index.js"); + expect(mod.FlowGraph).toBeDefined(); + }); +}); + +describe("FlowGraph cycle detection", () => { + it("prevents simple two-node cycle", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addEdge("a", "b"); + expect(() => fg.addEdge("b", "a")).toThrow(CycleError); + }); + + it("prevents longer cycle", () => { + 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"); + expect(() => fg.addEdge("d", "a")).toThrow(CycleError); + }); + + it("allows diamond DAG", () => { + 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"); + expect(fg.size).toBe(4); + expect(() => fg.addEdge("bottom", "top")).toThrow(CycleError); + }); + + it("does not create false positive cycle detection", () => { + const fg = new FlowGraph(); + fg.addNode("a", { name: "a" }); + fg.addNode("b", { name: "b" }); + fg.addNode("c", { name: "c" }); + fg.addEdge("a", "b"); + fg.addEdge("a", "c"); + expect(() => fg.addEdge("b", "c")).not.toThrow(); }); }); \ No newline at end of file