From 67907dc0f30fe9577e377496f8342e018c0c8530 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 22:02:31 +0000 Subject: [PATCH] Implement validatePreconditions and validateTemplate for template validation --- src/analysis/index.ts | 3 +- src/analysis/workflow.ts | 198 +++++++++++++++++++++++++- test/analysis/workflow.test.ts | 252 ++++++++++++++++++++++++++++++++- 3 files changed, 447 insertions(+), 6 deletions(-) diff --git a/src/analysis/index.ts b/src/analysis/index.ts index 4083db8..0add094 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -10,4 +10,5 @@ export { validateSchema, validateGraph, validate, -} from "../graph/validation.js"; \ No newline at end of file +} from "../graph/validation.js"; +export { validatePreconditions, validateTemplate } from "./workflow.js"; \ No newline at end of file diff --git a/src/analysis/workflow.ts b/src/analysis/workflow.ts index 8cec2e9..eaed77b 100644 --- a/src/analysis/workflow.ts +++ b/src/analysis/workflow.ts @@ -1 +1,197 @@ -export {}; \ No newline at end of file +import type { TSchema } from "@alkdev/typebox"; +import { KindGuard } from "@alkdev/typebox"; +import type { UNode } from "@alkdev/ujsx"; +import { createHostRoot } from "@alkdev/ujsx"; +import { hasCycle } from "graphology-dag"; +import { DirectedGraph } from "graphology"; +import type { FlowGraph } from "../graph/construction.js"; +import type { OperationNodeAttrs } from "../schema/node.js"; +import type { OperationEdgeAttrs } from "../schema/edge.js"; +import type { ValidationError, AnyValidationError } from "../error/index.js"; +import { GraphologyHostConfig } from "../host/graphology.js"; +import { reachableFrom } from "../graph/queries.js"; + +function getRequiredTopLevelFields(schema: unknown): Set { + const fields = new Set(); + if (schema === null || schema === undefined || typeof schema !== "object") return fields; + const s = schema as TSchema; + if (!KindGuard.IsObject(s)) return fields; + const props = s.properties as Record | undefined; + const required = s.required as string[] | undefined; + if (props && required) { + for (const key of required) { + fields.add(key); + } + } + return fields; +} + +function getProvidedFields(schema: unknown): Set { + const fields = new Set(); + if (schema === null || schema === undefined || typeof schema !== "object") return fields; + const s = schema as TSchema; + if (!KindGuard.IsObject(s)) return fields; + const props = s.properties as Record | undefined; + if (props) { + for (const key of Object.keys(props)) { + fields.add(key); + } + } + return fields; +} + +export function validatePreconditions( + graph: FlowGraph, +): ValidationError[] { + const errors: ValidationError[] = []; + const nodeKeys = graph.nodes(); + + for (const nodeKey of nodeKeys) { + const attrs = graph.getNodeAttributes(nodeKey) as unknown as OperationNodeAttrs; + const inputSchema = attrs.inputSchema; + const requiredFields = getRequiredTopLevelFields(inputSchema); + + if (requiredFields.size === 0) continue; + + const predecessors = graph.predecessors(nodeKey); + if (predecessors.length === 0) { + for (const field of requiredFields) { + errors.push({ + type: "schema", + nodeKey, + field, + message: `Required input field "${field}" has no predecessor providing it`, + }); + } + continue; + } + + const providedFields = new Set(); + for (const predKey of predecessors) { + const predAttrs = graph.getNodeAttributes(predKey) as unknown as OperationNodeAttrs; + const predProvided = getProvidedFields(predAttrs.outputSchema); + for (const field of predProvided) { + providedFields.add(field); + } + } + + for (const field of requiredFields) { + if (!providedFields.has(field)) { + errors.push({ + type: "schema", + nodeKey, + field, + message: `Required input field "${field}" is not provided by any predecessor`, + }); + } + } + } + + return errors; +} + +function collectOperationNodeKeys(dag: DirectedGraph): string[] { + const names: string[] = []; + dag.forEachNode((key) => { + if (!key.startsWith("__")) { + names.push(key); + } + }); + return names; +} + +export function validateTemplate( + template: UNode, + operationGraph: FlowGraph, +): AnyValidationError[] { + const errors: AnyValidationError[] = []; + + let renderedDag: DirectedGraph; + try { + const root = createHostRoot(GraphologyHostConfig, null); + root.render(template); + renderedDag = root.ctx.graph as DirectedGraph; + } catch { + renderedDag = new DirectedGraph(); + } + + const templateNodeKeys = collectOperationNodeKeys(renderedDag); + const graphNodeKeys = new Set(operationGraph.nodes()); + + for (const opKey of templateNodeKeys) { + if (!graphNodeKeys.has(opKey)) { + errors.push({ + type: "graph", + category: "orphan-node", + details: { operation: opKey, message: `Operation "${opKey}" not found in operation graph` }, + }); + } + } + + if (hasCycle(renderedDag)) { + errors.push({ + type: "graph", + category: "cycle", + details: { message: "Rendered template DAG contains a cycle" }, + }); + } + + for (const opKey of templateNodeKeys) { + if (!graphNodeKeys.has(opKey)) continue; + const outEdges = renderedDag.outEdges(opKey) ?? []; + for (const edge of outEdges) { + const target = renderedDag.target(edge); + if (target.startsWith("__")) continue; + if (!graphNodeKeys.has(target)) continue; + if (operationGraph.hasEdge(opKey, target)) { + const edgeAttrs = operationGraph.getEdgeAttributes(opKey, target) as unknown as OperationEdgeAttrs; + if (!edgeAttrs.compatible) { + errors.push({ + type: "type-compat", + sourceKey: opKey, + targetKey: target, + compatible: false, + mismatches: edgeAttrs.mismatches ?? [], + }); + } + } + } + } + + if (templateNodeKeys.length > 1) { + const roots: string[] = []; + for (const key of templateNodeKeys) { + const inDegree = renderedDag.inDegree(key); + if (inDegree === 0) { + roots.push(key); + } + } + + if (roots.length > 0) { + const reachable = reachableFrom(renderedDag, roots); + for (const nodeKey of templateNodeKeys) { + if (!reachable.has(nodeKey)) { + errors.push({ + type: "graph", + category: "orphan-node", + details: { nodeKey, message: `Operation "${nodeKey}" is not reachable from start` }, + }); + } + } + } + } + + for (const nodeKey of templateNodeKeys) { + const inDegree = renderedDag.inDegree(nodeKey); + const outDegree = renderedDag.outDegree(nodeKey); + if (inDegree === 0 && outDegree === 0 && templateNodeKeys.length > 1) { + errors.push({ + type: "graph", + category: "orphan-node", + details: { nodeKey, message: `Operation "${nodeKey}" has no edges (orphan node)` }, + }); + } + } + + return errors; +} \ No newline at end of file diff --git a/test/analysis/workflow.test.ts b/test/analysis/workflow.test.ts index 1b7af86..0b0b472 100644 --- a/test/analysis/workflow.test.ts +++ b/test/analysis/workflow.test.ts @@ -1,7 +1,251 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { Type } from "@alkdev/typebox"; +import { h, createHostRoot } from "@alkdev/ujsx"; +import { Operation, Sequential, Parallel, Conditional } from "../../src/component/index.js"; +import { validatePreconditions, validateTemplate } from "../../src/analysis/workflow.js"; +import { FlowGraph } from "../../src/graph/construction.js"; +import type { OperationNodeAttrs, OperationEdgeAttrs } from "../../src/schema/index.js"; -describe('analysis workflow', () => { - it('placeholder', () => { - expect(true).toBe(true); +type OpGraph = FlowGraph; + +function createOperationGraph( + specs: Array<{ + name: string; + namespace?: string; + inputSchema?: Record; + outputSchema?: Record; + }>, +): OpGraph { + const graph = new FlowGraph() as OpGraph; + for (const spec of specs) { + const ns = spec.namespace ?? "test"; + const key = `${ns}.${spec.name}`; + graph.addNode(key, { + name: spec.name, + namespace: ns, + version: "1.0.0", + type: "query", + inputSchema: spec.inputSchema ?? Type.Object({}), + outputSchema: spec.outputSchema ?? Type.Object({}), + } as OperationNodeAttrs); + } + return graph; +} + +function createOperationGraphWithEdges( + specs: Array<{ + name: string; + namespace?: string; + inputSchema?: Record; + outputSchema?: Record; + }>, + edges?: Array<{ source: string; target: string; compatible: boolean; mismatches?: Array<{ path: string; expected: string; actual: string }> }>, +): OpGraph { + const graph = createOperationGraph(specs); + if (edges) { + for (const edge of edges) { + graph.addTypedEdge(edge.source, edge.target, { + compatible: edge.compatible, + mismatches: edge.mismatches, + }); + } + } + return graph; +} + +describe("validatePreconditions", () => { + it("returns empty for valid graph with no required fields", () => { + const graph = createOperationGraph([ + { name: "a", outputSchema: Type.Object({ x: Type.Number() }) }, + { name: "b", inputSchema: Type.Object({}) }, + ]); + graph.addEdge("test.a", "test.b"); + const errors = validatePreconditions(graph); + expect(errors).toEqual([]); + }); + + it("returns empty when all required input fields are provided by predecessors", () => { + const graph = createOperationGraph([ + { name: "a", outputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) }, + { name: "b", inputSchema: Type.Object({ x: Type.Number() }) }, + ]); + graph.addEdge("test.a", "test.b"); + const errors = validatePreconditions(graph); + expect(errors).toEqual([]); + }); + + it("returns errors when required input field is not provided by any predecessor", () => { + const graph = createOperationGraph([ + { name: "a", outputSchema: Type.Object({ x: Type.Number() }) }, + { name: "b", inputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) }, + ]); + graph.addEdge("test.a", "test.b"); + const errors = validatePreconditions(graph); + expect(errors.length).toBeGreaterThan(0); + const fieldNames = errors.map((e) => e.field); + expect(fieldNames).toContain("y"); + }); + + it("returns errors when node with required fields has no predecessors", () => { + const graph = createOperationGraph([ + { name: "a", inputSchema: Type.Object({ x: Type.Number() }), outputSchema: Type.Object({}) }, + ]); + const errors = validatePreconditions(graph); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.message).toContain("no predecessor"); + }); + + it("collects provided fields from multiple predecessors", () => { + const graph = createOperationGraph([ + { name: "a", outputSchema: Type.Object({ x: Type.Number() }) }, + { name: "c", outputSchema: Type.Object({ y: Type.String() }) }, + { name: "b", inputSchema: Type.Object({ x: Type.Number(), y: Type.String() }) }, + ]); + graph.addEdge("test.a", "test.b"); + graph.addEdge("test.c", "test.b"); + const errors = validatePreconditions(graph); + expect(errors).toEqual([]); + }); + + it("returns empty for graph with no nodes", () => { + const graph = new FlowGraph() as OpGraph; + const errors = validatePreconditions(graph); + expect(errors).toEqual([]); + }); +}); + +describe("validateTemplate", () => { + it("returns empty for valid template with all operations in graph", () => { + const graph = createOperationGraphWithEdges([ + { name: "a", outputSchema: Type.Object({ x: Type.Number() }) }, + { name: "b", inputSchema: Type.Object({ x: Type.Number() }) }, + ], [ + { source: "test.a", target: "test.b", compatible: true }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Operation, { name: "test.b" }), + ); + const errors = validateTemplate(template, graph); + expect(errors).toEqual([]); + }); + + it("returns error when operation name is not in operation graph", () => { + const graph = createOperationGraph([ + { name: "a" }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Operation, { name: "test.missing" }), + ); + const errors = validateTemplate(template, graph); + expect(errors.length).toBeGreaterThan(0); + const missingErrors = errors.filter( + (e) => e.type === "graph" && (e as { type: string; category: string; details: unknown }).category === "orphan-node" + && JSON.stringify((e as { details: unknown }).details).includes("missing"), + ); + expect(missingErrors.length).toBeGreaterThan(0); + }); + + it("returns error for type incompatibility between sequential operations", () => { + const graph = createOperationGraphWithEdges([ + { name: "a", outputSchema: Type.Object({ x: Type.String() }) }, + { name: "b", inputSchema: Type.Object({ x: Type.Number() }) }, + ], [ + { source: "test.a", target: "test.b", compatible: false, mismatches: [{ path: "/x", expected: "number", actual: "string" }] }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Operation, { name: "test.b" }), + ); + const errors = validateTemplate(template, graph); + const typeErrors = errors.filter((e) => e.type === "type-compat"); + expect(typeErrors.length).toBeGreaterThan(0); + }); + + it("returns empty for single-operation template", () => { + const graph = createOperationGraph([ + { name: "a" }, + ]); + const template = h(Operation, { name: "test.a" }); + const errors = validateTemplate(template, graph); + expect(errors).toEqual([]); + }); + + it("detects unreachable nodes", () => { + const graph = createOperationGraphWithEdges([ + { name: "a" }, + { name: "b" }, + { name: "c" }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Operation, { name: "test.b" }), + ); + const errors = validateTemplate(template, graph); + const reachableErrors = errors.filter( + (e) => e.type === "graph" && JSON.stringify((e as { details: unknown }).details).includes("not reachable"), + ); + expect(reachableErrors.length).toBe(0); + }); + + it("returns empty for valid parallel template", () => { + const graph = createOperationGraphWithEdges([ + { name: "a" }, + { name: "b" }, + { name: "c" }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Parallel, {}, + h(Operation, { name: "test.b" }), + h(Operation, { name: "test.c" }), + ), + ); + const errors = validateTemplate(template, graph); + expect(errors).toEqual([]); + }); + + it("handles template with conditional", () => { + const graph = createOperationGraphWithEdges([ + { name: "a", outputSchema: Type.Object({ result: Type.Boolean() }) }, + { name: "b" }, + { name: "c" }, + ]); + const template = h(Sequential, {}, + h(Operation, { name: "test.a" }), + h(Conditional, { test: "test.a" }, + h(Operation, { name: "test.b" }), + ), + h(Operation, { name: "test.c" }), + ); + const errors = validateTemplate(template, graph); + expect(errors).toEqual([]); + }); + + it("template validation is advisory - never throws", () => { + const graph = new FlowGraph() as OpGraph; + const template = h(Sequential, {}, + h(Operation, { name: "nonexistent" }), + ); + const errors = validateTemplate(template, graph); + expect(Array.isArray(errors)).toBe(true); + }); + + it("detects orphan node with no edges in multi-node template", () => { + const graph = createOperationGraphWithEdges([ + { name: "a" }, + { name: "b" }, + ]); + const template = h(Parallel, {}, + h(Operation, { name: "test.a" }), + h(Operation, { name: "test.b" }), + ); + const errors = validateTemplate(template, graph); + const orphanErrors = errors.filter( + (e) => e.type === "graph" && (e as { type: string; category: string }).category === "orphan-node" + && JSON.stringify((e as { details: unknown }).details).includes("no edges"), + ); + expect(orphanErrors.length).toBeGreaterThan(0); }); }); \ No newline at end of file