diff --git a/src/analysis/index.ts b/src/analysis/index.ts index 4083db8..68cf0dc 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -4,8 +4,7 @@ export { defaultEdgeType, resolveDefaultNodeAttrs, } from "./defaults.js"; -export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; -export { buildTypeEdges } from "../graph/construction.js"; +export { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; export { validateSchema, validateGraph, diff --git a/src/analysis/type-compat.ts b/src/analysis/type-compat.ts index ed36cb0..018c9e1 100644 --- a/src/analysis/type-compat.ts +++ b/src/analysis/type-compat.ts @@ -1,4 +1,11 @@ import { KindGuard, Kind, type TSchema } from "@alkdev/typebox"; +import { willCreateCycle } from "graphology-dag"; +import type { FlowGraph } from "../graph/construction.js"; +import { + OperationNodeAttrs as OperationNodeAttrsSchema, + OperationEdgeAttrs as OperationEdgeAttrsSchema, +} from "../schema/index.js"; +import type { OperationNodeAttrs } from "../schema/index.js"; export interface TypeMismatch { path: string; @@ -275,4 +282,25 @@ export function typeCompat(outputSchema: TSchema, inputSchema: TSchema): TypeCom } return { compatible: false, mismatches }; +} + +export function buildTypeEdges(graph: FlowGraph): void { + const nodeKeys = graph.nodes(); + for (const source of nodeKeys) { + for (const target of nodeKeys) { + if (source === target) continue; + const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs; + const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs; + const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema); + if (result === undefined) continue; + if (graph.hasEdge(source, target)) continue; + if (willCreateCycle(graph.graph, source, target)) continue; + const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`; + graph.addTypedEdge(source, target, { + compatible: result.compatible, + detail, + ...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}), + }); + } + } } \ No newline at end of file diff --git a/src/graph/construction.ts b/src/graph/construction.ts index 3f8b6dd..96e3837 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -21,8 +21,10 @@ import { OperationGraphSerialized, CallGraphSerialized, } from "../schema/index.js"; -import type { OperationNodeAttrs, FlowGraphSerialized } from "../schema/index.js"; -import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js"; +import type { FlowGraphSerialized } from "../schema/index.js"; +import { buildTypeEdges, type TypeCompatResult } from "../analysis/type-compat.js"; + +export { buildTypeEdges } from "../analysis/type-compat.js"; export interface FlowGraphOptions { type?: "directed"; @@ -455,25 +457,4 @@ export class FlowGraph< } return []; } -} - -export function buildTypeEdges(graph: OperationGraph): void { - const nodeKeys = graph.nodes(); - for (const source of nodeKeys) { - for (const target of nodeKeys) { - if (source === target) continue; - const sourceAttrs = graph.getNodeAttributes(source as never) as unknown as OperationNodeAttrs; - const targetAttrs = graph.getNodeAttributes(target as never) as unknown as OperationNodeAttrs; - const result = typeCompat(sourceAttrs.outputSchema as TSchema, targetAttrs.inputSchema as TSchema); - if (result === undefined) continue; - if (graph.hasEdge(source, target)) continue; - if (willCreateCycle(graph.graph, source, target)) continue; - const detail = result.detail ?? `${sourceAttrs.namespace}.${sourceAttrs.name}.output → ${targetAttrs.namespace}.${targetAttrs.name}.input`; - graph.addTypedEdge(source, target, { - compatible: result.compatible, - detail, - ...(result.mismatches !== undefined ? { mismatches: result.mismatches } : {}), - }); - } - } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 37319a2..a7e44e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from "./error/index.js"; export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec } from "./graph/index.js"; +export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./analysis/type-compat.js"; export { validateSchema, validateGraph, diff --git a/test/analysis/type-compat.test.ts b/test/analysis/type-compat.test.ts index 1d8b072..237b263 100644 --- a/test/analysis/type-compat.test.ts +++ b/test/analysis/type-compat.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { Type, type TSchema } from "@alkdev/typebox"; -import { typeCompat, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js"; +import { typeCompat, buildTypeEdges, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js"; +import { FlowGraph } from "../../src/graph/construction.js"; +import type { OperationSpec } from "../../src/graph/construction.js"; describe("typeCompat", () => { describe("exact match", () => { @@ -411,4 +413,111 @@ describe("typeCompat", () => { expect(typeof mismatch.actual).toBe("string"); }); }); +}); + +describe("buildTypeEdges", () => { + it("adds compatible edges for matching output→input schemas", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }); + fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }); + buildTypeEdges(fg); + expect(fg.hasEdge("task.extract", "task.classify")).toBe(true); + const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record; + expect(attrs.edgeType).toBe("typed"); + expect(attrs.compatible).toBe(true); + }); + + it("adds incompatible edges when schemas mismatch", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }); + fg.addOperation({ name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) }); + buildTypeEdges(fg); + expect(fg.hasEdge("task.classify", "task.count")).toBe(true); + const attrs = fg.getEdgeAttributes("task.classify", "task.count") as Record; + expect(attrs.edgeType).toBe("typed"); + expect(attrs.compatible).toBe(false); + expect(attrs.mismatches).toBeDefined(); + }); + + it("incompatible edges include mismatches array", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ value: Type.String() }) }); + fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ value: Type.Number() }), outputSchema: Type.Object({ z: Type.Boolean() }) }); + buildTypeEdges(fg); + const attrs = fg.getEdgeAttributes("op.a", "op.b") as Record; + expect(attrs.compatible).toBe(false); + expect(Array.isArray(attrs.mismatches)).toBe(true); + expect((attrs.mismatches as Array).length).toBeGreaterThan(0); + }); + + it("does not add edges when either schema is Unknown", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "unk_out", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() }); + fg.addOperation({ name: "unk_in", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) }); + fg.addOperation({ name: "normal", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ x: Type.String() }) }); + buildTypeEdges(fg); + expect(fg.hasEdge("op.unk_out", "op.unk_in")).toBe(false); + expect(fg.hasEdge("op.unk_out", "op.normal")).toBe(false); + expect(fg.hasEdge("op.normal", "op.unk_in")).toBe(false); + }); + + it("sets detail to namespace.name.output → namespace.name.input for compatible edges", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }); + fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }); + buildTypeEdges(fg); + const attrs = fg.getEdgeAttributes("task.extract", "task.classify") as Record; + expect(attrs.detail).toContain("task.extract.output → task.classify.input"); + }); + + it("is callable after incremental addOperation calls", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "extract", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }); + buildTypeEdges(fg); + expect(fg.size).toBe(0); + fg.addOperation({ name: "classify", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }); + buildTypeEdges(fg); + expect(fg.hasEdge("op.extract", "op.classify")).toBe(true); + }); + + it("produces edges for three operations in a pipeline", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }); + fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }) }); + fg.addOperation({ name: "enrich", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ label: Type.String() }), outputSchema: Type.Object({ enriched: Type.String() }) }); + buildTypeEdges(fg); + expect(fg.hasEdge("task.extract", "task.classify")).toBe(true); + expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true); + expect(fg.hasEdge("task.extract", "task.enrich")).toBe(true); + const e2c = fg.getEdgeAttributes("task.extract", "task.classify") as Record; + const c2e = fg.getEdgeAttributes("task.classify", "task.enrich") as Record; + const e2e = fg.getEdgeAttributes("task.extract", "task.enrich") as Record; + expect(e2c.compatible).toBe(true); + expect(c2e.compatible).toBe(true); + expect(e2e.compatible).toBe(false); + }); + + it("does not add self-loops", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ x: Type.String() }) }); + buildTypeEdges(fg); + expect(fg.size).toBe(0); + }); + + it("returns empty graph with no edges for empty graph", () => { + const fg = new FlowGraph(); + buildTypeEdges(fg); + expect(fg.order).toBe(0); + expect(fg.size).toBe(0); + }); + + it("skips edges that would already exist", () => { + const fg = new FlowGraph(); + fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ y: Type.String() }) }); + fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) }); + buildTypeEdges(fg); + const sizeAfterFirst = fg.size; + buildTypeEdges(fg); + expect(fg.size).toBe(sizeAfterFirst); + }); }); \ No newline at end of file