Implement graph validation functions (validateSchema, validateGraph, validate)
This commit is contained in:
@@ -5,3 +5,8 @@ export {
|
|||||||
resolveDefaultNodeAttrs,
|
resolveDefaultNodeAttrs,
|
||||||
} from "./defaults.js";
|
} from "./defaults.js";
|
||||||
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
||||||
|
export {
|
||||||
|
validateSchema,
|
||||||
|
validateGraph,
|
||||||
|
validate,
|
||||||
|
} from "../graph/validation.js";
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
NodeNotFoundError,
|
NodeNotFoundError,
|
||||||
CycleError,
|
CycleError,
|
||||||
} from "../error/index.js";
|
} from "../error/index.js";
|
||||||
|
import { validate as _validate } from "./validation.js";
|
||||||
|
import type { AnyValidationError } from "../error/index.js";
|
||||||
|
|
||||||
export interface FlowGraphOptions {
|
export interface FlowGraphOptions {
|
||||||
type?: "directed";
|
type?: "directed";
|
||||||
@@ -154,6 +156,10 @@ export class FlowGraph<
|
|||||||
return this._graph.outNeighbors(nodeId) ?? [];
|
return this._graph.outNeighbors(nodeId) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate(schema: TSchema): AnyValidationError[] {
|
||||||
|
return _validate(this, schema as NodeAttrs);
|
||||||
|
}
|
||||||
|
|
||||||
static fromSpecs(
|
static fromSpecs(
|
||||||
_specs: unknown[],
|
_specs: unknown[],
|
||||||
): FlowGraph<TSchema, TSchema> {
|
): FlowGraph<TSchema, TSchema> {
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
export { FlowGraph, type FlowGraphOptions } from "./construction.js";
|
export { FlowGraph, type FlowGraphOptions } from "./construction.js";
|
||||||
|
export {
|
||||||
|
validateSchema,
|
||||||
|
validateGraph,
|
||||||
|
validate,
|
||||||
|
} from "./validation.js";
|
||||||
@@ -1 +1,152 @@
|
|||||||
export {};
|
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<N extends TSchema>(
|
||||||
|
graph: FlowGraph<N, TSchema>,
|
||||||
|
schema: N,
|
||||||
|
): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
graph.forEachNode((nodeKey, attrs) => {
|
||||||
|
const iter = Value.Errors(schema, attrs as Record<string, unknown>);
|
||||||
|
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<TSchema, TSchema>): string[][] {
|
||||||
|
const dg = g.graph;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const recStack = new Set<string>();
|
||||||
|
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<TSchema, TSchema>,
|
||||||
|
): 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<N extends TSchema>(
|
||||||
|
graph: FlowGraph<N, TSchema>,
|
||||||
|
schema: N,
|
||||||
|
): AnyValidationError[] {
|
||||||
|
const schemaErrors = validateSchema(graph, schema);
|
||||||
|
const graphErrors = validateGraph(graph);
|
||||||
|
return [...schemaErrors, ...graphErrors];
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
export * from "./error/index.js";
|
export * from "./error/index.js";
|
||||||
|
|
||||||
export { FlowGraph, type FlowGraphOptions } from "./graph/index.js";
|
export { FlowGraph, type FlowGraphOptions } from "./graph/index.js";
|
||||||
|
export {
|
||||||
|
validateSchema,
|
||||||
|
validateGraph,
|
||||||
|
validate,
|
||||||
|
} from "./graph/validation.js";
|
||||||
@@ -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', () => {
|
const SimpleNodeSchema = Type.Object({
|
||||||
it('placeholder', () => {
|
name: Type.String(),
|
||||||
expect(true).toBe(true);
|
value: Type.Number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SimpleNode = Static<typeof SimpleNodeSchema>;
|
||||||
|
|
||||||
|
function createValidGraph(): FlowGraph<typeof SimpleNodeSchema, TSchema> {
|
||||||
|
const g = new FlowGraph<typeof SimpleNodeSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("a", { name: "alpha" });
|
||||||
|
const errors = validateSchema(
|
||||||
|
g as FlowGraph<typeof SimpleNodeSchema, TSchema>,
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("a", { name: "alpha", value: "not-a-number" });
|
||||||
|
const errors = validateSchema(
|
||||||
|
g as FlowGraph<typeof SimpleNodeSchema, TSchema>,
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("a", { name: "alpha" });
|
||||||
|
g.graph.addNode("b", { value: 1 });
|
||||||
|
const errors = validateSchema(
|
||||||
|
g as FlowGraph<typeof SimpleNodeSchema, TSchema>,
|
||||||
|
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<typeof SimpleNodeSchema, TSchema>();
|
||||||
|
const errors = validateSchema(g, SimpleNodeSchema);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on invalid data", () => {
|
||||||
|
const g = new FlowGraph<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("a", {});
|
||||||
|
expect(() =>
|
||||||
|
validateSchema(g as FlowGraph<typeof SimpleNodeSchema, TSchema>, 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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("orphan", { name: "orphan" });
|
||||||
|
g.graph.addNode("invalid", { name: 123 });
|
||||||
|
const errors = validate(
|
||||||
|
g as FlowGraph<typeof SimpleNodeSchema, TSchema>,
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.graph.addNode("a", { name: "wrong-type", value: "not-a-number" });
|
||||||
|
const errors = validate(
|
||||||
|
g as FlowGraph<typeof SimpleNodeSchema, TSchema>,
|
||||||
|
SimpleNodeSchema,
|
||||||
|
);
|
||||||
|
for (const error of errors) {
|
||||||
|
expect(["schema", "graph", "type-compat"]).toContain(error.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty graph returns empty errors", () => {
|
||||||
|
const g = new FlowGraph<typeof SimpleNodeSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
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<TSchema, TSchema>();
|
||||||
|
g.addNode("orphan", { name: "orphan" });
|
||||||
|
const errors = g.validate(SimpleNodeSchema);
|
||||||
|
const graphErrors = errors.filter((e) => e.type === "graph");
|
||||||
|
expect(graphErrors.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user