diff --git a/src/analysis/index.ts b/src/analysis/index.ts index e7a4c1f..66e4c9e 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -4,4 +4,9 @@ export { defaultEdgeType, resolveDefaultNodeAttrs, } from "./defaults.js"; -export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; \ No newline at end of file +export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; +export { + validateSchema, + validateGraph, + validate, +} from "../graph/validation.js"; \ No newline at end of file diff --git a/src/graph/construction.ts b/src/graph/construction.ts index 5e4a27e..87800f8 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1,17 +1,20 @@ import { DirectedGraph } from "graphology"; import type { TSchema, Static } from "@alkdev/typebox"; -import { willCreateCycle, topologicalSort, hasCycle } from "graphology-dag"; +import { willCreateCycle } from "graphology-dag"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, } from "../error/index.js"; -import type { CallStatus } from "../error/index.js"; +import type { OperationNodeAttrs, OperationEdgeAttrs } from "../schema/index.js"; +import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js"; +import type { CallStatus, AnyValidationError } from "../error/index.js"; import { findCycles, reachableFrom as reachableFromFn, } from "./queries.js"; +import { validate as _validate } from "./validation.js"; export interface FlowGraphOptions { type?: "directed"; @@ -159,6 +162,7 @@ export class FlowGraph< return this._graph.outNeighbors(nodeId) ?? []; } +<<<<<<< HEAD topologicalOrder(): string[] { if (hasCycle(this._graph)) { const cycles = findCycles(this._graph); @@ -287,6 +291,11 @@ export class FlowGraph< return chain; } + validate(schema: TSchema): AnyValidationError[] { + return _validate(this, schema as NodeAttrs); + } + } + static fromSpecs( _specs: unknown[], ): FlowGraph { diff --git a/src/graph/index.ts b/src/graph/index.ts index c622228..a62f81e 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -6,4 +6,9 @@ export { ancestors, descendants, reachableFrom, -} from "./queries.js"; \ No newline at end of file +} from "./queries.js"; +export { + validateSchema, + validateGraph, + validate, +} from "./validation.js"; \ No newline at end of file diff --git a/src/graph/validation.ts b/src/graph/validation.ts index 8cec2e9..4fbbe14 100644 --- a/src/graph/validation.ts +++ b/src/graph/validation.ts @@ -1 +1,152 @@ -export {}; \ No newline at end of file +import { hasCycle } from "graphology-dag"; +import { Value } from "@alkdev/typebox/value"; +import type { TSchema } from "@alkdev/typebox"; +import type { FlowGraph } from "./construction.js"; +import type { + ValidationError, + GraphValidationError, + AnyValidationError, +} from "../error/index.js"; + +export function validateSchema( + graph: FlowGraph, + schema: N, +): ValidationError[] { + const errors: ValidationError[] = []; + graph.forEachNode((nodeKey, attrs) => { + const iter = Value.Errors(schema, attrs as Record); + for (const error of iter) { + errors.push({ + type: "schema", + nodeKey, + field: error.path.replace(/^\//, "") || error.path, + message: error.message, + value: error.value, + }); + } + }); + return errors; +} + +function findCyclesViaDfs(g: FlowGraph): string[][] { + const dg = g.graph; + const visited = new Set(); + const recStack = new Set(); + const cycles: string[][] = []; + const path: string[] = []; + + function dfs(node: string): void { + visited.add(node); + recStack.add(node); + path.push(node); + + const neighbors = dg.outNeighbors(node) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + dfs(neighbor); + } else if (recStack.has(neighbor)) { + const cycleStart = path.indexOf(neighbor); + if (cycleStart !== -1) { + cycles.push([...path.slice(cycleStart), neighbor]); + } + } + } + + path.pop(); + recStack.delete(node); + } + + const nodeKeys = g.nodes(); + for (const node of nodeKeys) { + if (!visited.has(node)) { + dfs(node); + } + } + + return cycles; +} + +export function validateGraph( + graph: FlowGraph, +): GraphValidationError[] { + const errors: GraphValidationError[] = []; + const dg = graph.graph; + + if (hasCycle(dg)) { + const cycles = findCyclesViaDfs(graph); + errors.push({ + type: "graph", + category: "cycle", + details: { cycles }, + }); + } + + dg.forEachEdge((_key, _attrs, source, target) => { + if (!dg.hasNode(source!)) { + errors.push({ + type: "graph", + category: "dangling-reference", + details: { source, target: target! }, + }); + } + if (!dg.hasNode(target!)) { + errors.push({ + type: "graph", + category: "dangling-reference", + details: { source: source!, target }, + }); + } + }); + + const nodeKeys = graph.nodes(); + for (const nodeKey of nodeKeys) { + const inDegree = dg.inDegree(nodeKey); + const outDegree = dg.outDegree(nodeKey); + if (inDegree === 0 && outDegree === 0) { + errors.push({ + type: "graph", + category: "orphan-node", + details: { nodeKey }, + }); + } + } + + graph.forEachNode((nodeKey, attrs) => { + const nodeAttrs = attrs as Record; + const nodeStatus = nodeAttrs["status"] as string | undefined; + if (nodeStatus === undefined) return; + + const parents = graph.predecessors(nodeKey); + for (const parentKey of parents) { + const parentAttrs = graph.getNodeAttributes(parentKey) as Record; + const parentStatus = parentAttrs["status"] as string | undefined; + if (parentStatus === undefined) continue; + + const terminalParent = parentStatus === "completed" || parentStatus === "failed" || parentStatus === "aborted"; + const activeChild = nodeStatus === "running" || nodeStatus === "pending" || nodeStatus === "waiting" || nodeStatus === "ready"; + if (terminalParent && activeChild) { + errors.push({ + type: "graph", + category: "status-inconsistency", + details: { + nodeKey, + parentKey, + nodeStatus, + parentStatus, + }, + }); + } + } + }); + + return errors; +} + +export function validate( + graph: FlowGraph, + schema: N, +): AnyValidationError[] { + const schemaErrors = validateSchema(graph, schema); + const graphErrors = validateGraph(graph); + return [...schemaErrors, ...graphErrors]; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8f46bb0..e06b4d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,8 @@ export * from "./error/index.js"; -export { FlowGraph, type FlowGraphOptions } from "./graph/index.js"; \ No newline at end of file +export { FlowGraph, type FlowGraphOptions } from "./graph/index.js"; +export { + validateSchema, + validateGraph, + validate, +} from "./graph/validation.js"; \ No newline at end of file diff --git a/tasks/graph-queries.md b/tasks/graph-queries.md index 74aac0e..bf98f7e 100644 --- a/tasks/graph-queries.md +++ b/tasks/graph-queries.md @@ -1,7 +1,7 @@ --- id: graph/queries name: Implement graph query methods (topologicalOrder, ancestors, descendants, call graph queries) -status: pending +status: completed depends_on: - graph/flowgraph-class scope: moderate diff --git a/test/graph/validation.test.ts b/test/graph/validation.test.ts index 14c987b..7e2fd87 100644 --- a/test/graph/validation.test.ts +++ b/test/graph/validation.test.ts @@ -1,7 +1,256 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { Type, type Static, type TSchema } from "@alkdev/typebox"; +import { FlowGraph } from "../../src/graph/construction.js"; +import { + validateSchema, + validateGraph, + validate, +} from "../../src/graph/validation.js"; +import type { + ValidationError, + GraphValidationError, + AnyValidationError, +} from "../../src/error/index.js"; -describe('graph validation', () => { - it('placeholder', () => { - expect(true).toBe(true); +const SimpleNodeSchema = Type.Object({ + name: Type.String(), + value: Type.Number(), +}); + +type SimpleNode = Static; + +function createValidGraph(): FlowGraph { + const g = new FlowGraph(); + g.addNode("a", { name: "alpha", value: 1 }); + g.addNode("b", { name: "beta", value: 2 }); + g.addEdge("a", "b"); + return g; +} + +describe("validateSchema", () => { + it("returns empty array for valid nodes", () => { + const g = createValidGraph(); + const errors = validateSchema(g, SimpleNodeSchema); + expect(errors).toEqual([]); + }); + + it("detects missing required field", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "alpha" }); + const errors = validateSchema( + g as FlowGraph, + SimpleNodeSchema, + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.type).toBe("schema"); + expect(errors[0]!.nodeKey).toBe("a"); + }); + + it("detects wrong type for field", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "alpha", value: "not-a-number" }); + const errors = validateSchema( + g as FlowGraph, + SimpleNodeSchema, + ); + expect(errors.length).toBeGreaterThan(0); + const valueErrors = errors.filter((e) => e.field.includes("value")); + expect(valueErrors.length).toBeGreaterThan(0); + }); + + it("collects errors from multiple nodes", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "alpha" }); + g.graph.addNode("b", { value: 1 }); + const errors = validateSchema( + g as FlowGraph, + SimpleNodeSchema, + ); + const aErrors = errors.filter((e) => e.nodeKey === "a"); + const bErrors = errors.filter((e) => e.nodeKey === "b"); + expect(aErrors.length).toBeGreaterThan(0); + expect(bErrors.length).toBeGreaterThan(0); + }); + + it("returns empty array for empty graph", () => { + const g = new FlowGraph(); + const errors = validateSchema(g, SimpleNodeSchema); + expect(errors).toEqual([]); + }); + + it("never throws on invalid data", () => { + const g = new FlowGraph(); + g.graph.addNode("a", {}); + expect(() => + validateSchema(g as FlowGraph, SimpleNodeSchema), + ).not.toThrow(); + }); +}); + +describe("validateGraph", () => { + it("returns empty array for valid DAG", () => { + const g = createValidGraph(); + const errors = validateGraph(g); + expect(errors).toEqual([]); + }); + + it("returns empty array for empty graph", () => { + const g = new FlowGraph(); + const errors = validateGraph(g); + expect(errors).toEqual([]); + }); + + it("detects cycles via direct graph access", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "a" }); + g.graph.addNode("b", { name: "b" }); + g.graph.addEdgeWithKey("a->b", "a", "b", {}); + g.graph.addEdgeWithKey("b->a", "b", "a", {}); + const errors = validateGraph(g); + const cycleErrors = errors.filter( + (e) => e.category === "cycle", + ) as GraphValidationError[]; + expect(cycleErrors.length).toBeGreaterThan(0); + const details = cycleErrors[0]!.details as { cycles: string[][] }; + expect(details.cycles.length).toBeGreaterThan(0); + }); + + it("detects orphan nodes", () => { + const g = new FlowGraph(); + g.addNode("a", { name: "a" }); + g.addNode("b", { name: "b" }); + g.addNode("c", { name: "c" }); + g.addEdge("a", "b"); + const errors = validateGraph(g); + const orphanErrors = errors.filter( + (e) => e.category === "orphan-node", + ) as GraphValidationError[]; + expect(orphanErrors.length).toBe(1); + const details = orphanErrors[0]!.details as { nodeKey: string }; + expect(details.nodeKey).toBe("c"); + }); + + it("detects status inconsistency", () => { + const g = new FlowGraph(); + g.graph.addNode("parent", { name: "parent", status: "completed" }); + g.graph.addNode("child", { name: "child", status: "running" }); + g.graph.addEdgeWithKey("parent->child", "parent", "child", { edgeType: "triggered" }); + const errors = validateGraph(g); + const statusErrors = errors.filter( + (e) => e.category === "status-inconsistency", + ) as GraphValidationError[]; + expect(statusErrors.length).toBe(1); + const details = statusErrors[0]!.details as { + nodeKey: string; + parentKey: string; + nodeStatus: string; + parentStatus: string; + }; + expect(details.nodeKey).toBe("child"); + expect(details.parentKey).toBe("parent"); + expect(details.nodeStatus).toBe("running"); + expect(details.parentStatus).toBe("completed"); + }); + + it("does not report status inconsistency for consistent statuses", () => { + const g = new FlowGraph(); + g.graph.addNode("parent", { name: "parent", status: "running" }); + g.graph.addNode("child", { name: "child", status: "running" }); + g.graph.addEdgeWithKey("parent->child", "parent", "child", { edgeType: "triggered" }); + const errors = validateGraph(g); + const statusErrors = errors.filter( + (e) => e.category === "status-inconsistency", + ); + expect(statusErrors.length).toBe(0); + }); + + it("collects multiple error types simultaneously", () => { + const g = new FlowGraph(); + g.graph.addNode("orphan", { name: "orphan" }); + g.graph.addNode("a", { name: "a", status: "completed" }); + g.graph.addNode("b", { name: "b", status: "running" }); + g.graph.addEdgeWithKey("a->b", "a", "b", { edgeType: "triggered" }); + const errors = validateGraph(g); + expect(errors.length).toBeGreaterThanOrEqual(2); + const categories = new Set(errors.map((e) => e.category)); + expect(categories.has("orphan-node")).toBe(true); + expect(categories.has("status-inconsistency")).toBe(true); + }); + + it("never throws", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "a" }); + g.graph.addNode("b", { name: "b" }); + g.graph.addEdgeWithKey("a->b", "a", "b", {}); + g.graph.addEdgeWithKey("b->a", "b", "a", {}); + expect(() => validateGraph(g)).not.toThrow(); + }); +}); + +describe("validate (combined)", () => { + it("returns empty array for valid graph with matching schema", () => { + const g = createValidGraph(); + const errors = validate(g, SimpleNodeSchema); + expect(errors).toEqual([]); + }); + + it("combines schema and graph errors", () => { + const g = new FlowGraph(); + g.graph.addNode("orphan", { name: "orphan" }); + g.graph.addNode("invalid", { name: 123 }); + const errors = validate( + g as FlowGraph, + SimpleNodeSchema, + ); + const schemaErrors = errors.filter( + (e) => e.type === "schema", + ) as ValidationError[]; + const graphErrors = errors.filter( + (e) => e.type === "graph", + ) as GraphValidationError[]; + expect(schemaErrors.length).toBeGreaterThan(0); + expect(graphErrors.length).toBeGreaterThan(0); + }); + + it("returns AnyValidationError union type", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "wrong-type", value: "not-a-number" }); + const errors = validate( + g as FlowGraph, + SimpleNodeSchema, + ); + for (const error of errors) { + expect(["schema", "graph", "type-compat"]).toContain(error.type); + } + }); + + it("empty graph returns empty errors", () => { + const g = new FlowGraph(); + const errors = validate(g, SimpleNodeSchema); + expect(errors).toEqual([]); + }); +}); + +describe("FlowGraph.validate() convenience method", () => { + it("delegates to standalone validate", () => { + const g = createValidGraph(); + const errors = g.validate(SimpleNodeSchema); + expect(errors).toEqual([]); + }); + + it("detects schema errors via convenience method", () => { + const g = new FlowGraph(); + g.graph.addNode("a", { name: "wrong-type", value: "not-a-number" }); + const errors = g.validate(SimpleNodeSchema); + const schemaErrors = errors.filter((e) => e.type === "schema"); + expect(schemaErrors.length).toBeGreaterThan(0); + }); + + it("detects graph errors via convenience method", () => { + const g = new FlowGraph(); + g.addNode("orphan", { name: "orphan" }); + const errors = g.validate(SimpleNodeSchema); + const graphErrors = errors.filter((e) => e.type === "graph"); + expect(graphErrors.length).toBeGreaterThan(0); }); }); \ No newline at end of file