import { describe, it, expect } from "vitest"; import { Type, type TSchema } from "@alkdev/typebox"; 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", () => { it("identical string schemas are compatible", () => { const result = typeCompat(Type.String(), Type.String()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("identical number schemas are compatible", () => { const result = typeCompat(Type.Number(), Type.Number()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("identical boolean schemas are compatible", () => { const result = typeCompat(Type.Boolean(), Type.Boolean()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("identical object schemas are compatible", () => { const output = Type.Object({ name: Type.String(), age: Type.Number() }); const input = Type.Object({ name: Type.String(), age: Type.Number() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); }); describe("superset (output has extra fields)", () => { it("output with extra fields is still compatible", () => { const output = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() }); const input = Type.Object({ name: Type.String(), age: Type.Number() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); expect(result!.detail).toBe("output has extra fields beyond input requirements"); }); it("output with many extra fields is compatible", () => { const output = Type.Object({ a: Type.String(), b: Type.Number(), c: Type.Boolean(), d: Type.String() }); const input = Type.Object({ a: Type.String() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); }); describe("subset (output missing required fields)", () => { it("output missing a required field is incompatible", () => { const output = Type.Object({ name: Type.String() }); const input = Type.Object({ name: Type.String(), age: Type.Number() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); expect(result!.mismatches).toBeDefined(); expect(result!.mismatches!.length).toBeGreaterThan(0); const ageMismatch = result!.mismatches!.find((m) => m.path === "/age"); expect(ageMismatch).toBeDefined(); expect(ageMismatch!.expected).toBe("number"); expect(ageMismatch!.actual).toBe("missing"); }); it("output missing multiple required fields reports all mismatches", () => { const output = Type.Object({ name: Type.String() }); const input = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const paths = result!.mismatches!.map((m) => m.path); expect(paths).toContain("/age"); expect(paths).toContain("/email"); }); }); describe("type mismatch", () => { it("string output vs number input is incompatible", () => { const output = Type.Object({ value: Type.String() }); const input = Type.Object({ value: Type.Number() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const mismatch = result!.mismatches!.find((m) => m.path === "/value"); expect(mismatch).toBeDefined(); expect(mismatch!.expected).toBe("number"); expect(mismatch!.actual).toBe("string"); }); it("boolean output vs string input is incompatible", () => { const output = Type.Object({ flag: Type.Boolean() }); const input = Type.Object({ flag: Type.String() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); }); describe("unknown passthrough", () => { it("output is Type.Unknown() returns undefined", () => { const result = typeCompat(Type.Unknown(), Type.String()); expect(result).toBeUndefined(); }); it("input is Type.Unknown() returns undefined", () => { const result = typeCompat(Type.String(), Type.Unknown()); expect(result).toBeUndefined(); }); it("both schemas are Type.Unknown() returns undefined", () => { const result = typeCompat(Type.Unknown(), Type.Unknown()); expect(result).toBeUndefined(); }); }); describe("nested objects (recursive)", () => { it("deeply nested objects with exact match are compatible", () => { const output = Type.Object({ address: Type.Object({ city: Type.String(), zip: Type.String(), }), }); const input = Type.Object({ address: Type.Object({ city: Type.String(), zip: Type.String(), }), }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("nested object with superset fields is compatible", () => { const output = Type.Object({ address: Type.Object({ city: Type.String(), zip: Type.String(), country: Type.String(), }), }); const input = Type.Object({ address: Type.Object({ city: Type.String(), zip: Type.String(), }), }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("nested object missing required field is incompatible", () => { const output = Type.Object({ address: Type.Object({ city: Type.String(), }), }); const input = Type.Object({ address: Type.Object({ city: Type.String(), zip: Type.String(), }), }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const mismatch = result!.mismatches!.find((m) => m.path === "/address/zip"); expect(mismatch).toBeDefined(); }); it("nested object with type mismatch reports correct path", () => { const output = Type.Object({ address: Type.Object({ city: Type.Number(), }), }); const input = Type.Object({ address: Type.Object({ city: Type.String(), }), }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const mismatch = result!.mismatches!.find((m) => m.path === "/address/city"); expect(mismatch).toBeDefined(); expect(mismatch!.expected).toBe("string"); expect(mismatch!.actual).toBe("number"); }); }); describe("arrays", () => { it("identical array element types are compatible", () => { const result = typeCompat(Type.Array(Type.String()), Type.Array(Type.String())); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("array with incompatible element types is not compatible", () => { const result = typeCompat(Type.Array(Type.Number()), Type.Array(Type.String())); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); expect(result!.mismatches!.length).toBeGreaterThan(0); }); it("output array with union element type is not compatible with string array input", () => { const result = typeCompat( Type.Array(Type.Union([Type.String(), Type.Number()])), Type.Array(Type.String()), ); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); it("output array with string element is compatible with union array input", () => { const result = typeCompat( Type.Array(Type.String()), Type.Array(Type.Union([Type.String(), Type.Number()])), ); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); }); describe("optional fields", () => { it("optional field in input that is present in output is compatible", () => { const output = Type.Object({ name: Type.String(), email: Type.String() }); const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("optional field in input that is absent in output is compatible", () => { const output = Type.Object({ name: Type.String() }); const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("required field in input that is absent in output is not compatible", () => { const output = Type.Object({ name: Type.String() }); const input = Type.Object({ name: Type.String(), age: Type.Number() }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); }); describe("union types (subtype checking)", () => { it("string output is compatible with string|number input", () => { const result = typeCompat(Type.String(), Type.Union([Type.String(), Type.Number()])); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("string|number output is NOT compatible with string input", () => { const result = typeCompat(Type.Union([Type.String(), Type.Number()]), Type.String()); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); it("number output is compatible with string|number input", () => { const result = typeCompat(Type.Number(), Type.Union([Type.String(), Type.Number()])); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("literal output is compatible with matching union input", () => { const result = typeCompat(Type.Literal("hello"), Type.Union([Type.Literal("hello"), Type.Literal("world")])); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("literal output is NOT compatible with non-matching union input", () => { const result = typeCompat(Type.Literal("other"), Type.Union([Type.Literal("hello"), Type.Literal("world")])); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); }); describe("literal types", () => { it("identical string literals are compatible", () => { const result = typeCompat(Type.Literal("hello"), Type.Literal("hello")); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("different string literals are not compatible", () => { const result = typeCompat(Type.Literal("hello"), Type.Literal("world")); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); it("string literal output is compatible with string input", () => { const result = typeCompat(Type.Literal("hello"), Type.String()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("number literal output is compatible with number input", () => { const result = typeCompat(Type.Literal(42), Type.Number()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); }); describe("integer and number compatibility", () => { it("integer output is compatible with number input", () => { const result = typeCompat(Type.Integer(), Type.Number()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("number output is NOT compatible with integer input", () => { const result = typeCompat(Type.Number(), Type.Integer()); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); }); }); describe("complex realistic schemas", () => { it("realistic operation schemas - classify output compatible with enrich input", () => { const classifyOutput = Type.Object({ categories: Type.Array(Type.String()), confidence: Type.Number(), metadata: Type.Optional(Type.Object({ model: Type.String(), version: Type.String(), })), }); const enrichInput = Type.Object({ categories: Type.Array(Type.String()), confidence: Type.Number(), }); const result = typeCompat(classifyOutput, enrichInput); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); }); it("realistic operation schemas - incompatible due to field type", () => { const fetchOutput = Type.Object({ items: Type.Array(Type.Object({ id: Type.String(), name: Type.String() })), total: Type.Number(), }); const processInput = Type.Object({ items: Type.Array(Type.Object({ id: Type.Number(), name: Type.String() })), total: Type.Number(), }); const result = typeCompat(fetchOutput, processInput); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const mismatch = result!.mismatches!.find((m) => m.path.includes("id")); expect(mismatch).toBeDefined(); }); it("multiple mismatches at different depths", () => { const output = Type.Object({ name: Type.Number(), address: Type.Object({ city: Type.Number(), zip: Type.Boolean(), }), }); const input = Type.Object({ name: Type.String(), address: Type.Object({ city: Type.String(), zip: Type.String(), }), }); const result = typeCompat(output, input); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); expect(result!.mismatches!.length).toBeGreaterThanOrEqual(3); }); }); describe("TypeCompatResult shape", () => { it("compatible result has no mismatches", () => { const result = typeCompat(Type.String(), Type.String()); expect(result).toBeDefined(); expect(result!.compatible).toBe(true); expect(result!.mismatches).toBeUndefined(); }); it("incompatible result has mismatches array", () => { const result = typeCompat(Type.String(), Type.Number()); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); expect(Array.isArray(result!.mismatches)).toBe(true); expect(result!.mismatches!.length).toBeGreaterThan(0); }); it("TypeMismatch has correct shape", () => { const result = typeCompat( Type.Object({ value: Type.String() }), Type.Object({ value: Type.Number() }), ); expect(result).toBeDefined(); expect(result!.compatible).toBe(false); const mismatch: TypeMismatch = result!.mismatches![0]!; expect(typeof mismatch.path).toBe("string"); expect(typeof mismatch.expected).toBe("string"); 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); }); });