Merge branch 'feat/analysis-build-type-edges'

# Conflicts:
#	src/analysis/index.ts
#	src/graph/construction.ts
#	src/index.ts
This commit is contained in:
2026-05-21 22:08:42 +00:00
7 changed files with 146 additions and 28 deletions

View File

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