From 5514c5aaccfd72edecdcd230d30354b22f7439a9 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:28:27 +0000 Subject: [PATCH] feat(graph): implement fromSpecs, addOperation, addTypedEdge, buildTypeEdges --- src/analysis/index.ts | 3 +- src/graph/construction.ts | 92 ++++++++++- src/graph/index.ts | 2 +- src/index.ts | 2 +- test/graph/construction.test.ts | 282 +++++++++++++++++++++++++++++++- 5 files changed, 368 insertions(+), 13 deletions(-) diff --git a/src/analysis/index.ts b/src/analysis/index.ts index e7a4c1f..55c09cc 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -4,4 +4,5 @@ export { defaultEdgeType, resolveDefaultNodeAttrs, } from "./defaults.js"; -export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; \ No newline at end of file +export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; +export { buildTypeEdges } from "../graph/construction.js"; \ No newline at end of file diff --git a/src/graph/construction.ts b/src/graph/construction.ts index c00cbad..8af2b0b 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -7,6 +7,12 @@ import { NodeNotFoundError, CycleError, } from "../error/index.js"; +import { + OperationNodeAttrs as OperationNodeAttrsSchema, + OperationEdgeAttrs as OperationEdgeAttrsSchema, +} from "../schema/index.js"; +import type { OperationNodeAttrs } from "../schema/index.js"; +import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js"; export interface FlowGraphOptions { type?: "directed"; @@ -14,6 +20,26 @@ export interface FlowGraphOptions { allowSelfLoops?: false; } +export interface OperationSpec { + name: string; + namespace: string; + version: string; + type: "query" | "mutation" | "subscription"; + inputSchema: TSchema; + outputSchema: TSchema; + description?: string; + tags?: string[]; +} + +type OperationGraph = FlowGraph; + +type TypedEdgeAttrs = { + edgeType: "typed"; + compatible: boolean; + detail?: string; + mismatches?: TypeCompatResult["mismatches"]; +}; + type Attrs = Record; export class FlowGraph< @@ -154,10 +180,47 @@ export class FlowGraph< return this._graph.outNeighbors(nodeId) ?? []; } - static fromSpecs( - _specs: unknown[], - ): FlowGraph { - throw new Error("not implemented"); + addOperation(spec: OperationSpec): void { + const key = `${spec.namespace}.${spec.name}`; + this.addNode(key, { + name: spec.name, + namespace: spec.namespace, + version: spec.version, + type: spec.type, + inputSchema: spec.inputSchema, + outputSchema: spec.outputSchema, + ...(spec.description !== undefined ? { description: spec.description } : {}), + ...(spec.tags !== undefined ? { tags: spec.tags } : {}), + } as Static); + } + + addTypedEdge( + source: string, + target: string, + attrs: { compatible: boolean; detail?: string; mismatches?: TypeCompatResult["mismatches"] }, + ): void { + if (!this._graph.hasNode(source)) { + throw new NodeNotFoundError(source); + } + if (!this._graph.hasNode(target)) { + throw new NodeNotFoundError(target); + } + const edgeAttrs: TypedEdgeAttrs = { + edgeType: "typed", + compatible: attrs.compatible, + ...(attrs.detail !== undefined ? { detail: attrs.detail } : {}), + ...(attrs.mismatches !== undefined ? { mismatches: attrs.mismatches } : {}), + }; + this.addEdge(source, target, edgeAttrs as Static); + } + + static fromSpecs(specs: OperationSpec[]): OperationGraph { + const graph = new FlowGraph(); + for (const spec of specs) { + graph.addOperation(spec); + } + buildTypeEdges(graph); + return graph; } static fromCallEvents( @@ -196,4 +259,25 @@ 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/graph/index.ts b/src/graph/index.ts index 86cd5b9..8c1be0c 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1 +1 @@ -export { FlowGraph, type FlowGraphOptions } from "./construction.js"; \ No newline at end of file +export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec } from "./construction.js"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8f46bb0..6242429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export * from "./error/index.js"; -export { FlowGraph, type FlowGraphOptions } from "./graph/index.js"; \ No newline at end of file +export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec } from "./graph/index.js"; \ No newline at end of file diff --git a/test/graph/construction.test.ts b/test/graph/construction.test.ts index ee84256..68d3f8c 100644 --- a/test/graph/construction.test.ts +++ b/test/graph/construction.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { FlowGraph } from "../../src/graph/construction.js"; +import { Type } from "@alkdev/typebox"; +import { FlowGraph, buildTypeEdges } from "../../src/graph/construction.js"; +import type { OperationSpec } from "../../src/graph/construction.js"; import { DuplicateNodeError, DuplicateEdgeError, @@ -298,10 +300,6 @@ describe("FlowGraph query methods", () => { }); describe("FlowGraph static stubs", () => { - it("fromSpecs throws not implemented", () => { - expect(() => FlowGraph.fromSpecs([])).toThrow("not implemented"); - }); - it("fromCallEvents throws not implemented", () => { expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented"); }); @@ -311,11 +309,283 @@ describe("FlowGraph static stubs", () => { }); }); +describe("FlowGraph.addOperation", () => { + it("adds an operation node with namespace.name key", () => { + const fg = new FlowGraph(); + const spec: OperationSpec = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ text: Type.String() }), + outputSchema: Type.Object({ label: Type.String() }), + }; + fg.addOperation(spec); + expect(fg.hasNode("task.classify")).toBe(true); + const attrs = fg.getNodeAttributes("task.classify") as Record; + expect(attrs.name).toBe("classify"); + expect(attrs.namespace).toBe("task"); + expect(attrs.version).toBe("1.0.0"); + expect(attrs.type).toBe("query"); + }); + + it("throws DuplicateNodeError for duplicate operation", () => { + const fg = new FlowGraph(); + const spec: OperationSpec = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ text: Type.String() }), + outputSchema: Type.Object({ label: Type.String() }), + }; + fg.addOperation(spec); + expect(() => fg.addOperation(spec)).toThrow(DuplicateNodeError); + }); + + it("includes optional description and tags", () => { + const fg = new FlowGraph(); + const spec: OperationSpec = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ text: Type.String() }), + outputSchema: Type.Object({ label: Type.String() }), + description: "Classifies text", + tags: ["nlp", "ml"], + }; + fg.addOperation(spec); + const attrs = fg.getNodeAttributes("task.classify") as Record; + expect(attrs.description).toBe("Classifies text"); + expect(attrs.tags).toEqual(["nlp", "ml"]); + }); +}); + +describe("FlowGraph.addTypedEdge", () => { + it("adds a typed edge with edgeType: 'typed'", () => { + const fg = new FlowGraph(); + const spec1: OperationSpec = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ text: Type.String() }), + outputSchema: Type.Object({ label: Type.String() }), + }; + const spec2: OperationSpec = { + name: "enrich", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ label: Type.String() }), + outputSchema: Type.Object({ enriched: Type.String() }), + }; + fg.addOperation(spec1); + fg.addOperation(spec2); + fg.addTypedEdge("task.classify", "task.enrich", { compatible: true }); + expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true); + const edgeAttrs = fg.getEdgeAttributes("task.classify", "task.enrich") as Record; + expect(edgeAttrs.edgeType).toBe("typed"); + expect(edgeAttrs.compatible).toBe(true); + }); + + it("throws NodeNotFoundError if source doesn't exist", () => { + const fg = new FlowGraph(); + const spec: OperationSpec = { + name: "enrich", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ label: Type.String() }), + outputSchema: Type.Object({ enriched: Type.String() }), + }; + fg.addOperation(spec); + expect(() => fg.addTypedEdge("task.missing", "task.enrich", { compatible: true })).toThrow(NodeNotFoundError); + }); + + it("throws NodeNotFoundError if target doesn't exist", () => { + const fg = new FlowGraph(); + const spec: OperationSpec = { + name: "classify", + namespace: "task", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ text: Type.String() }), + outputSchema: Type.Object({ label: Type.String() }), + }; + fg.addOperation(spec); + expect(() => fg.addTypedEdge("task.classify", "task.missing", { compatible: true })).toThrow(NodeNotFoundError); + }); + + it("includes detail and mismatches when provided", () => { + const fg = new FlowGraph(); + const spec1: OperationSpec = { + name: "a", + namespace: "op", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ x: Type.String() }), + outputSchema: Type.Object({ y: Type.Number() }), + }; + const spec2: OperationSpec = { + name: "b", + namespace: "op", + version: "1.0.0", + type: "query", + inputSchema: Type.Object({ x: Type.String() }), + outputSchema: Type.Object({ z: Type.Boolean() }), + }; + fg.addOperation(spec1); + fg.addOperation(spec2); + fg.addTypedEdge("op.a", "op.b", { + compatible: false, + detail: "output mismatch", + mismatches: [{ path: "/y", expected: "string", actual: "number" }], + }); + const edgeAttrs = fg.getEdgeAttributes("op.a", "op.b") as Record; + expect(edgeAttrs.compatible).toBe(false); + expect(edgeAttrs.detail).toBe("output mismatch"); + expect(edgeAttrs.mismatches).toEqual([{ path: "/y", expected: "string", actual: "number" }]); + }); + + it("throws CycleError if edge would create cycle", () => { + 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({ x: Type.String() }), + }); + fg.addTypedEdge("op.a", "op.b", { compatible: true }); + expect(() => fg.addTypedEdge("op.b", "op.a", { compatible: true })).toThrow(CycleError); + }); +}); + +describe("FlowGraph.fromSpecs", () => { + it("creates operation nodes for each spec", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { 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() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.order).toBe(2); + expect(graph.hasNode("task.extract")).toBe(true); + expect(graph.hasNode("task.classify")).toBe(true); + }); + + it("adds type-compatibility edges via buildTypeEdges", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { 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() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.size).toBeGreaterThan(0); + }); + + it("adds compatible edge when output matches input", () => { + const specs: OperationSpec[] = [ + { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, + { 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() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.hasEdge("task.extract", "task.classify")).toBe(true); + const attrs = graph.getEdgeAttributes("task.extract", "task.classify") as Record; + expect(attrs.compatible).toBe(true); + }); + + it("adds incompatible edge when output does not match input", () => { + const specs: OperationSpec[] = [ + { 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() }) }, + { name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.hasEdge("task.classify", "task.count")).toBe(true); + const attrs = graph.getEdgeAttributes("task.classify", "task.count") as Record; + expect(attrs.compatible).toBe(false); + }); + + it("skips edges when target inputSchema is Unknown", () => { + const specs: OperationSpec[] = [ + { name: "source", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ result: Type.String() }) }, + { name: "unk_input", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.hasEdge("op.source", "op.unk_input")).toBe(false); + }); + + it("skips edges when source outputSchema is Unknown", () => { + const specs: OperationSpec[] = [ + { name: "unk_output", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() }, + { name: "target", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ result: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) }, + ]; + const graph = FlowGraph.fromSpecs(specs); + expect(graph.hasEdge("op.unk_output", "op.target")).toBe(false); + }); + + it("throws DuplicateNodeError for duplicate specs", () => { + const stringOutput = Type.Object({ label: Type.String() }); + const specs: OperationSpec[] = [ + { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput }, + { name: "classify", namespace: "task", version: "2.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput }, + ]; + expect(() => FlowGraph.fromSpecs(specs)).toThrow(DuplicateNodeError); + }); + + it("returns empty graph for empty specs", () => { + const graph = FlowGraph.fromSpecs([]); + expect(graph.order).toBe(0); + expect(graph.size).toBe(0); + }); +}); + +describe("buildTypeEdges (standalone)", () => { + it("adds edges to an incrementally constructed graph", () => { + 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() }), + }); + expect(fg.size).toBe(0); + buildTypeEdges(fg); + expect(fg.size).toBeGreaterThan(0); + expect(fg.hasEdge("task.extract", "task.classify")).toBe(true); + }); + + it("is callable after adding more operations", () => { + 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(), score: Type.Number() }), + }); + buildTypeEdges(fg); + expect(fg.hasEdge("op.extract", "op.classify")).toBe(true); + }); +}); + describe("FlowGraph re-exports", () => { it("is re-exported from src/graph/index.ts", async () => { const mod = await import("../../src/graph/index.js"); expect(mod.FlowGraph).toBeDefined(); - expect(mod.FlowGraphOptions).toBeUndefined(); + expect(mod.buildTypeEdges).toBeDefined(); }); it("is re-exported from src/index.ts", async () => {