Files
flowgraph/test/analysis/type-compat.test.ts
glm-5.1 fa2223b90b feat: move buildTypeEdges to src/analysis/type-compat.ts as standalone function
Relocate buildTypeEdges from construction.ts to type-compat.ts per architecture spec.
construction.ts re-exports it for backward compatibility. Add 10 unit tests for
buildTypeEdges covering compatible edges, incompatible edges with mismatches,
unknown schema passthrough, incremental construction, and edge deduplication.
2026-05-21 22:06:26 +00:00

523 lines
22 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(attrs.compatible).toBe(false);
expect(Array.isArray(attrs.mismatches)).toBe(true);
expect((attrs.mismatches as Array<unknown>).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<string, unknown>;
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<string, unknown>;
const c2e = fg.getEdgeAttributes("task.classify", "task.enrich") as Record<string, unknown>;
const e2e = fg.getEdgeAttributes("task.extract", "task.enrich") as Record<string, unknown>;
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);
});
});