Merge feat/graph-validation: resolve conflicts in construction.ts and index.ts

This commit is contained in:
2026-05-21 21:19:21 +00:00
7 changed files with 435 additions and 11 deletions

View File

@@ -4,4 +4,9 @@ export {
defaultEdgeType,
resolveDefaultNodeAttrs,
} 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";

View File

@@ -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<TSchema, TSchema> {

View File

@@ -6,4 +6,9 @@ export {
ancestors,
descendants,
reachableFrom,
} from "./queries.js";
} from "./queries.js";
export {
validateSchema,
validateGraph,
validate,
} from "./validation.js";

View File

@@ -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];
}

View File

@@ -1,3 +1,8 @@
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";