diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index 3338290..a498239 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -109,9 +109,6 @@ export const getSignatureSchema = ( : type ) - // Identify parameter dependencies based on type parameters - const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes) - // Track which parameter slots actually carry a user-supplied value. The merge // uses this as a last-resort signal: if the function- and node-side schemas // both came out generic but the user did set something, the lift falls back @@ -120,6 +117,9 @@ export const getSignatureSchema = ( (p) => p?.value != null ) + // Identify parameter dependencies based on type parameters + const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes, valueProvidedByIndex) + // Generate schema for each parameter const parameters = generateNodeSchemas( checker, @@ -280,15 +280,19 @@ const extractFunctionParameterTypes = ( /** * Identifies parameter dependencies based on shared type parameters. * Determines which parameters depend on type parameters declared in other parameters. - * If an argument is explicitly provided (not null/undefined), it is not blocked. + * A dependency is cleared once either the depended-on parameter carries a value + * (so the shared type parameter is pinned by the user's choice) or the dependent + * parameter's own argument already resolves to a concrete type. * * @param funktion - The function declaration to analyze * @param nodeParameterTypes + * @param valueProvidedByIndex - Whether each parameter slot carries a user-supplied value * @returns Array of ParameterDependency objects */ const getParameterDependencies = ( funktion: ts.FunctionDeclaration, - nodeParameterTypes: ts.Type[] | undefined + nodeParameterTypes: ts.Type[] | undefined, + valueProvidedByIndex: boolean[] = [], ): ParameterDependency[] => { const typeParamNames = funktion.typeParameters?.map((tp) => tp.name.getText()) || [] const usage: Record = {} @@ -347,6 +351,11 @@ const getParameterDependencies = ( // Filter heraus, was durch echte Werte (nicht null/undefined) bereits aufgelöst ist return rawDependencies.filter((dep) => { + // Providing the depended-on parameter pins the shared type parameter, so + // the dependent is unblocked — even when the value carries no element type + // to infer from (e.g. an empty list literal `[]`). + if (valueProvidedByIndex[dep.dependsOnIndex]) return false + const resolvedType = nodeParameterTypes[dep.parameterIndex] // Falls aus irgendeinem Grund kein Typ ermittelt werden konnte -> blocked lassen diff --git a/test/schema/circular.test.ts b/test/schema/circular.test.ts new file mode 100644 index 0000000..cafcf46 --- /dev/null +++ b/test/schema/circular.test.ts @@ -0,0 +1,235 @@ +import {describe, expect, it} from "vitest"; +import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; +import {getSignatureSchema} from "../../src"; +import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; + +// A single two-parameter node calling `identifier`, probed at that node. Returns +// the input kind and blockedBy list for each of the two parameters. +const probeTwoParam = ( + identifier: string, + extraDataTypes: DataType[], + extraFunctions: FunctionDefinition[], + v0: any, + v1: any, +) => { + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier}, + parameters: {nodes: [{value: v0}, {value: v1}]}, + }, + ], + }, + }; + return getSignatureSchema( + flow, + [...DATA_TYPES, ...extraDataTypes], + [...FUNCTION_SIGNATURES, ...extraFunctions], + "gid://sagittarius/NodeFunction/1", + ).parameters.map((p) => ({input: p.schema.input, blockedBy: p.blockedBy})); +}; + +const lit = (value: any) => ({__typename: "LiteralValue", value}); + +describe("Schema — circular / conditional parameter dependencies", () => { + + // ───────────────────────────────────────────────────────────────────────── + // Scenario A: distinct generics, each used in exactly ONE parameter. + // (left: CIRC_LEFT, right: CIRC_RIGHT) + // The two parameters mutually depend on each other, but because no single + // generic is shared across both parameters, the shared-generic detector never + // records a dependency. Neither is ever blocked. + // ───────────────────────────────────────────────────────────────────────── + describe("distinct generics, one per parameter (dependency invisible)", () => { + const dataTypes: DataType[] = [ + {identifier: "CIRC_LEFT", genericKeys: ["T"], type: "T extends 'json' ? OBJECT<{}> : string"}, + {identifier: "CIRC_RIGHT", genericKeys: ["T"], type: "T extends string ? 'json' : 'text'"}, + ]; + const fn: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9100", + identifier: "custom::test::circular_distinct", + signature: "(left: CIRC_LEFT, right: CIRC_RIGHT): void", + }; + const probe = (v0: any, v1: any) => + probeTwoParam("custom::test::circular_distinct", dataTypes, [fn], v0, v1); + + it("never blocks either parameter, for any combination of values", () => { + for (const [l, r] of [ + [null, null], + [lit({}), null], + [null, lit("json")], + [lit({}), lit("json")], + ] as const) { + const [left, right] = probe(l, r); + expect(left.blockedBy).toEqual([]); + expect(right.blockedBy).toEqual([]); + } + }); + + it("resolves each conditional to its unbound-generic (false) branch → both text", () => { + // CIRC_LEFT → R extends 'json' ? OBJECT<{}> : string → string → "text" + // CIRC_RIGHT → L extends string ? 'json' : 'text' → 'text' → "text" + for (const [l, r] of [ + [null, null], + [lit({}), lit("json")], + ] as const) { + const [left, right] = probe(l, r); + expect(left.input).toBe("text"); + expect(right.input).toBe("text"); + } + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario B: both generics shared across both parameters, conditional falls + // back to a CONCRETE type. + // (left: CIRC_LEFT, right: CIRC_RIGHT) + // A dependency IS detected (right depends on left), but the second check drops + // it because `right`'s conditional always lands on a concrete branch — so it + // never looks unresolved. Nothing is blocked. + // ───────────────────────────────────────────────────────────────────────── + describe("shared generics, conditional with concrete fallback (never blocked)", () => { + const dataTypes: DataType[] = [ + {identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R extends string ? string : number"}, + {identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L extends string ? string : number"}, + ]; + const fn: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9101", + identifier: "custom::test::circular_concrete", + signature: "(left: CIRC_LEFT, right: CIRC_RIGHT): void", + }; + const probe = (v0: any, v1: any) => + probeTwoParam("custom::test::circular_concrete", dataTypes, [fn], v0, v1); + + it("never blocks either parameter, because the dependent always has a concrete type", () => { + for (const [l, r] of [ + [null, null], + [lit({}), null], + [null, lit({})], + [lit({}), lit({})], + ] as const) { + const [left, right] = probe(l, r); + expect(left.blockedBy).toEqual([]); + expect(right.blockedBy).toEqual([]); + // The unbound conditionals collapse to their innermost `number` branch. + expect(left.input).toBe("number"); + expect(right.input).toBe("number"); + } + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario C: both generics shared across both parameters, conditional falls + // back to a BARE generic (so nothing resolves until the other side binds it). + // (left: CIRC_LEFT, right: CIRC_RIGHT) + // Now the dependency both (1) is detected and (2) survives the concrete-type + // check, so `right` is genuinely blocked by `left`. + // ───────────────────────────────────────────────────────────────────────── + describe("shared generics, bare-generic fallback (right blocked by left)", () => { + const dataTypes: DataType[] = [ + {identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"}, + {identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L"}, + ]; + const fn: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9102", + identifier: "custom::test::circular_bare", + signature: "(left: CIRC_LEFT, right: CIRC_RIGHT): void", + }; + const probe = (v0: any, v1: any) => + probeTwoParam("custom::test::circular_bare", dataTypes, [fn], v0, v1); + + it("blocks right by left while nothing is set; left (index 0) is never blocked", () => { + const [left, right] = probe(null, null); + // left is the FIRST user of the shared generics → treated as the source, + // never blocked. + expect(left.blockedBy).toEqual([]); + expect(left.input).toBe("generic"); + // right depends on left. Both L and R produce the same dependency, so the + // index appears twice (the mechanism does not de-duplicate). + expect(right.blockedBy).toEqual([0, 0]); + expect(right.input).toBe("generic"); + }); + + it("unblocks right once left is provided", () => { + const [left, right] = probe(lit("json"), null); + expect(left.blockedBy).toEqual([]); + expect(right.blockedBy).toEqual([]); + }); + + it("never blocks left, whichever side is set", () => { + for (const [l, r] of [ + [null, null], + [lit("json"), null], + [null, lit("json")], + [lit("json"), lit("json")], + ] as const) { + expect(probe(l, r)[0].blockedBy).toEqual([]); + } + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario D: asymmetric — the dependent's generic sits only in the CHECK + // position of its own conditional, so it can never be inferred from the + // dependent's own value. + // CIRC_LEFT = L extends 'json' ? OBJECT<{}> : R (R inferable here) + // CIRC_RIGHT = R extends 'json' ? OBJECT<{}> : undefined (R only in check pos.) + // `right` can only be unblocked by `left` (which binds R); filling `right`'s + // own value does NOT unblock it. + // ───────────────────────────────────────────────────────────────────────── + describe("check-position generic (right unblockable only by left)", () => { + const dataTypes: DataType[] = [ + {identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"}, + {identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : undefined"}, + ]; + const fn: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9103", + identifier: "custom::test::circular_check", + signature: "(left: CIRC_LEFT, right: CIRC_RIGHT): void", + }; + const probe = (v0: any, v1: any) => + probeTwoParam("custom::test::circular_check", dataTypes, [fn], v0, v1); + + it("blocks right while left is empty", () => { + const [left, right] = probe(null, null); + expect(left.blockedBy).toEqual([]); + expect(right.blockedBy).toEqual([0, 0]); + }); + + it("does NOT unblock right when only right's own value is set", () => { + // right's generic R is only in the check position of its conditional, so + // TypeScript cannot infer R from right's value → right stays blocked on + // left. The input shows "data" merely because a value was supplied (the + // schema falls back to an open object for the UI), even though the block + // remains. + const [left, right] = probe(null, lit("json")); + expect(left.blockedBy).toEqual([]); + expect(right.blockedBy).toEqual([0, 0]); + expect(right.input).toBe("data"); + }); + + it("unblocks right when left is set (the only thing that binds R)", () => { + const [leftOnly] = [probe(lit("json"), null)]; + expect(leftOnly[1].blockedBy).toEqual([]); + + const bothSet = probe(lit("json"), lit("json")); + expect(bothSet[1].blockedBy).toEqual([]); + }); + + it("never blocks left", () => { + for (const [l, r] of [ + [null, null], + [lit("json"), null], + [null, lit("json")], + [lit("json"), lit("json")], + ] as const) { + expect(probe(l, r)[0].blockedBy).toEqual([]); + } + }); + }); +}); diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 4bbdc5f..7bbb959 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1050,6 +1050,146 @@ describe("Schema", () => { expect(paths).not.toContain("chain.next.next.next.next.next.next.value"); }); + it('unblocks std::list::push item once the list is provided, even as an empty literal', () => { + // std::list::push → (list: LIST, item: T): NUMBER + // `item` shares the type parameter T with `list`, so it starts out + // blockedBy [0]. Providing the `list` argument satisfies that dependency, + // so `item` becomes unblocked — an empty list literal `[]` counts as a + // provided value just like a concrete list does. + const buildPushFlow = (listValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::list::push"}, + parameters: { + nodes: [ + {value: listValue}, + {value: null}, + ], + }, + }, + ], + }, + }); + + // With no list provided, `item` is blocked by the list parameter (index 0). + const blockedResult = getSignatureSchema( + buildPushFlow(null), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + expect(blockedResult.parameters[1].blockedBy).toEqual([0]); + + // Providing an empty list literal `[]` satisfies the dependency, so the + // `item` parameter is now unblocked. + const emptyResult = getSignatureSchema( + buildPushFlow({__typename: "LiteralValue", value: []}), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + const [emptyList, emptyItem] = emptyResult.parameters; + + expect(emptyList.schema.input).toBe("list"); + expect(emptyList.blockedBy).toEqual([]); + expect(emptyItem.blockedBy).toEqual([]); + }); + + it('keeps std::list::push item blocked while the list has no value', () => { + // std::list::push → (list: LIST, item: T): NUMBER + // As long as the `list` parameter (index 0) carries no value, T cannot be + // pinned, so `item` (index 1) must stay blockedBy [0] and fall back to a + // generic input. This holds whether the list slot is `null` or an explicit + // null-typed value. + const buildPushFlow = (listValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::list::push"}, + parameters: { + nodes: [ + {value: listValue}, + {value: null}, + ], + }, + }, + ], + }, + }); + + // No parameter object at all for the list slot. + const nullResult = getSignatureSchema( + buildPushFlow(null), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + const [nullList, nullItem] = nullResult.parameters; + + // The list itself is never blocked — nothing feeds it. + expect(nullList.blockedBy).toEqual([]); + // item stays blocked by the list and has no concrete type yet. + expect(nullItem.blockedBy).toEqual([0]); + expect(nullItem.schema.input).toBe("generic"); + }); + + it('keeps a later std::list::push item blocked when its list references an earlier node', () => { + // Two-node flow. Node 1 (std::list::push) returns NUMBER. Node 2 is another + // std::list::push whose `list` parameter is still empty, so its `item` + // parameter must stay blockedBy [0] — an unrelated, already-configured + // predecessor node does not pin node 2's T. + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::list::push"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: [1, 2, 3]}}, + {value: {__typename: "LiteralValue", value: 4}}, + ], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::list::push"}, + parameters: { + nodes: [ + {value: null}, + {value: null}, + ], + }, + }, + ], + }, + }; + + const {parameters: [list, item]} = getSignatureSchema( + flow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/2", + ); + + expect(list.blockedBy).toEqual([]); + expect(item.blockedBy).toEqual([0]); + expect(item.schema.input).toBe("generic"); + }); + describe("return schema", () => { // Single-node flow calling `identifier` with the given parameters, probed at that node. const singleNode = (identifier: string, params: any[]): Flow => ({ @@ -1167,6 +1307,90 @@ describe("Schema", () => { expect(result.return).toEqual({input: "generic", type: "void"}); }); + // A trigger is analyzed at the flow level (no nodeId). The flow's own + // signature carries the return type, and its `settings` supply the + // arguments the generic return is instantiated from. This mirrors the + // REST trigger: (input_schema: TYPE, ...): REST_ADAPTER_INPUT. + const restTrigger = (inputSchema: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: + "(input_schema: TYPE, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", + settings: { + nodes: [ + {value: inputSchema}, + {value: "/users"}, + {value: "GET"}, + ], + }, + nodes: {nodes: []}, + } as Flow); + + it("resolves a trigger's return schema at the flow level (no nodeId) from its settings", () => { + const result = getSignatureSchema( + restTrigger({name: "text"}), + DATA_TYPES, + FUNCTION_SIGNATURES, + ); + + // Flow-level probe → no target node. + expect(result.nodeId).toBeUndefined(); + + // REST_ADAPTER_INPUT is an object → data input with its four fields. + const ret = result.return as { + input: string; + properties: Record; + required: string[]; + }; + expect(ret.input).toBe("data"); + expect(Object.keys(ret.properties)).toEqual( + expect.arrayContaining([ + "payload", + "headers", + "query_params", + "path_params", + ]), + ); + expect(ret.required).toEqual( + expect.arrayContaining([ + "payload", + "headers", + "query_params", + "path_params", + ]), + ); + + // T is bound from the input_schema setting ({name: TEXT}), so the + // payload keeps that concrete shape. + const payload = ret.properties.payload; + expect(payload.input).toBe("data"); + expect(Object.keys(payload.properties)).toEqual(["name"]); + expect(payload.properties.name.input).toBe("text"); + + // The remaining REST fields are open objects. + expect(ret.properties.headers.input).toBe("data"); + expect(ret.properties.query_params.input).toBe("data"); + expect(ret.properties.path_params.input).toBe("data"); + + // A return type describes an output → no suggestions anywhere. + expectNoSuggestionsAnywhere(ret); + }); + + it("instantiates the trigger's generic return payload from a primitive input_schema setting", () => { + const result = getSignatureSchema( + restTrigger(42), + DATA_TYPES, + FUNCTION_SIGNATURES, + ); + + expect(result.nodeId).toBeUndefined(); + + // input_schema = 42 → T = NUMBER → payload is a number input. + const ret = result.return as {properties: Record}; + expect(ret.properties.payload).toEqual({input: "number", type: "number"}); + expectNoSuggestionsAnywhere(ret); + }); + it("never carries suggestions, whatever the return type", () => { // Primitive, object, list and generic returns all stay suggestion-free. expectNoSuggestionsAnywhere(