From 1de49330177a883c395c626c22cdc10754bf5dac Mon Sep 17 00:00:00 2001 From: nicosammito Date: Wed, 15 Jul 2026 21:15:28 +0200 Subject: [PATCH 1/3] feat: refactor signature schema generation to include return type and improve structure --- src/schema/getSignatureSchema.ts | 75 +++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index 46df755..3338290 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -8,7 +8,6 @@ import {getSchema, mergeSchemas, Schema} from "../util/schema.util" * Includes the parameter's schema definition and any parameter dependencies that block it. */ export interface NodeSchema { - nodeId: NodeFunction["id"] /** * The schema definition for this node parameter. Produced by merging the * function-declared parameter schema with the node's concrete value schema: @@ -21,6 +20,20 @@ export interface NodeSchema { blockedBy?: number[] } +/** + * Represents the full schema information for a function signature. + * Wraps the per-parameter schemas together with the schema of the signature's + * return type. + */ +export interface SignatureSchema { + /** The analyzed node's ID, or undefined when the flow signature itself is analyzed */ + nodeId: NodeFunction["id"] + /** Schema for each parameter of the signature */ + parameters: NodeSchema[] + /** Schema describing the signature's return type */ + return: Schema +} + /** * Represents a parameter dependency relationship. * Indicates which parameters depend on type parameters defined in other parameters. @@ -57,7 +70,7 @@ export const getSignatureSchema = ( dataTypes: DataType[], functions: FunctionDefinition[], nodeId?: NodeFunction["id"], -): NodeSchema[] => { +): SignatureSchema => { // Generate TypeScript source code from the flow definition const sourceCode = generateFlowSourceCode(flow, functions, dataTypes) @@ -108,8 +121,7 @@ export const getSignatureSchema = ( ) // Generate schema for each parameter - return generateNodeSchemas( - nodeId, + const parameters = generateNodeSchemas( checker, node!, mergedParameterTypes, @@ -119,6 +131,58 @@ export const getSignatureSchema = ( nodeId ? functions : [], valueProvidedByIndex, ) + + // Resolve the signature's return type and build its schema. The return type + // describes the value the function produces, so it carries no input + // suggestions. + const returnType = extractReturnType(checker, node, funktion) + const returnSchema: Schema = returnType + ? getSchema( + checker, + node, + returnType, + Array.from(declaredFunctionsMap.values()), + functions, + false, + ) + : {input: "generic"} + + return {nodeId, parameters, return: returnSchema} +} + +/** + * Extracts the return type of the signature being analyzed. + * + * The node's call expression is preferred because its resolved signature + * substitutes concrete type arguments (e.g. `REST_ADAPTER_INPUT` becomes the + * instantiated type based on the supplied arguments). When no call expression is + * available, the function declaration's own signature is used as a fallback. + * + * @param checker - The TypeScript type checker + * @param node - The variable declaration containing the call expression + * @param funktion - The function declaration for the signature + * @returns The resolved return type, or undefined if it cannot be determined + */ +const extractReturnType = ( + checker: ts.TypeChecker, + node: ts.VariableDeclaration | undefined, + funktion: ts.FunctionDeclaration | undefined, +): Type | undefined => { + if (node?.initializer && ts.isCallExpression(node.initializer)) { + const signature = checker.getResolvedSignature(node.initializer) + if (signature) { + return checker.getReturnTypeOfSignature(signature) + } + } + + if (funktion) { + const signature = checker.getSignatureFromDeclaration(funktion) + if (signature) { + return checker.getReturnTypeOfSignature(signature) + } + } + + return undefined } /** @@ -301,7 +365,6 @@ const getParameterDependencies = ( * Generates node schemas for all parameters. * Creates schema objects for each parameter with their dependencies. * - * @param nodeId - * @param checker - The TypeScript type checker * @param node - The node's variable declaration * @param nodeParameterTypes - Merged parameter types to use for schema generation @@ -313,7 +376,6 @@ const getParameterDependencies = ( * @returns Array of NodeSchema objects */ const generateNodeSchemas = ( - nodeId: NodeFunction["id"], checker: ts.TypeChecker, node: ts.VariableDeclaration, nodeParameterTypes: Type[] | undefined, @@ -359,7 +421,6 @@ const generateNodeSchemas = ( : undefined return { - nodeId: nodeId, schema: mergeSchemas( functionSchema, nodeSchema, From 5ffb52d28c6151baf69886183fa9087eb19dc6ea Mon Sep 17 00:00:00 2001 From: nicosammito Date: Wed, 15 Jul 2026 21:15:34 +0200 Subject: [PATCH 2/3] feat: refactor tests to destructure parameters from getSignatureSchema and add return schema assertions --- test/schema/schema.test.ts | 169 ++++++++++++++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 12 deletions(-) diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 05fd7a7..e657868 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -305,7 +305,7 @@ describe("Schema", () => { }, }; - const [first] = getSignatureSchema( + const {parameters: [first]} = getSignatureSchema( flow, DATA_TYPES, FUNCTION_SIGNATURES, @@ -371,13 +371,13 @@ describe("Schema", () => { value: "hello", }); - const [refResult] = getSignatureSchema( + const {parameters: [refResult]} = getSignatureSchema( refFlow, DATA_TYPES, FUNCTION_SIGNATURES, "gid://sagittarius/NodeFunction/2", ); - const [literalResult] = getSignatureSchema( + const {parameters: [literalResult]} = getSignatureSchema( literalFlow, DATA_TYPES, FUNCTION_SIGNATURES, @@ -486,7 +486,7 @@ describe("Schema", () => { }); const probe = (returnValue: any) => { - const [r] = getSignatureSchema( + const {parameters: [r]} = getSignatureSchema( buildFlow(returnValue), DATA_TYPES, FUNCTION_SIGNATURES, @@ -563,7 +563,7 @@ describe("Schema", () => { }, }; - const [listSchema] = getSignatureSchema( + const {parameters: [listSchema]} = getSignatureSchema( flow, DATA_TYPES, FUNCTION_SIGNATURES, @@ -609,7 +609,7 @@ describe("Schema", () => { }, }; - const [first] = getSignatureSchema( + const {parameters: [first]} = getSignatureSchema( flow, DATA_TYPES, FUNCTION_SIGNATURES, @@ -669,7 +669,7 @@ describe("Schema", () => { ); // payload is the 4th parameter of rest::control::respond. - const payloadSchema = result[3]; + const payloadSchema = result.parameters[3]; // application/json → HTTP_PAYLOAD = OBJECT<{}> → open object input. expect(payloadSchema.schema.input).toBe("data"); @@ -731,7 +731,7 @@ describe("Schema", () => { }, }; - const [valueSchema] = getSignatureSchema( + const {parameters: [valueSchema]} = getSignatureSchema( flow, DATA_TYPES, [...FUNCTION_SIGNATURES, NULLABLE_TEXT_FN], @@ -799,7 +799,7 @@ describe("Schema", () => { }, }; - const [valueSchema] = getSignatureSchema( + const {parameters: [valueSchema]} = getSignatureSchema( flow, DATA_TYPES, [...FUNCTION_SIGNATURES, NULLABLE_OBJECT_FN], @@ -871,7 +871,7 @@ describe("Schema", () => { }, }; - const [valueSchema] = getSignatureSchema( + const {parameters: [valueSchema]} = getSignatureSchema( flow, DATA_TYPES, [...FUNCTION_SIGNATURES, NESTED_NULLABLE_OBJECT_FN], @@ -940,7 +940,7 @@ describe("Schema", () => { }, }; - const [valueSchema] = getSignatureSchema( + const {parameters: [valueSchema]} = getSignatureSchema( flow, DATA_TYPES, [...FUNCTION_SIGNATURES, DEEPLY_NESTED_FN], @@ -1019,7 +1019,7 @@ describe("Schema", () => { }, }; - const [valueSchema] = getSignatureSchema( + const {parameters: [valueSchema]} = getSignatureSchema( flow, [...DATA_TYPES, ...RECURSIVE_DATA_TYPES], [...FUNCTION_SIGNATURES, RECURSIVE_FN], @@ -1050,4 +1050,149 @@ describe("Schema", () => { expect(paths).not.toContain("chain.next.next.next.next.next.next.value"); }); + describe("return schema", () => { + // Single-node flow calling `identifier` with the given parameters, probed at that node. + const singleNode = (identifier: string, params: any[]): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier}, + parameters: {nodes: params}, + }, + ], + }, + }); + + const returnOf = (identifier: string, params: any[]) => + getSignatureSchema( + singleNode(identifier, params), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ).return; + + // Asserts every schema node in the tree (root, nested properties, list + // items) has no suggestions — a return schema describes an output, so it + // never carries input suggestions. + const expectNoSuggestionsAnywhere = (schema: any, path = "return"): void => { + expect( + schema.suggestions === undefined || + (Array.isArray(schema.suggestions) && schema.suggestions.length === 0), + `${path} unexpectedly has suggestions: ${JSON.stringify(schema.suggestions)}`, + ).toBe(true); + for (const [key, child] of Object.entries(schema.properties ?? {})) { + const children = Array.isArray(child) ? child : [child]; + children.forEach((c, i) => + expectNoSuggestionsAnywhere(c, `${path}.properties.${key}[${i}]`), + ); + } + (schema.items ?? []).forEach((item: any, i: number) => + expectNoSuggestionsAnywhere(item, `${path}.items[${i}]`), + ); + }; + + it("resolves a NUMBER return to a number input", () => { + // std::boolean::as_number → (value: BOOLEAN): NUMBER + const ret = returnOf("std::boolean::as_number", [ + {value: {__typename: "LiteralValue", value: true}}, + ]); + expect(ret).toEqual({input: "number"}); + }); + + it("resolves a TEXT return to a text input", () => { + // std::boolean::as_text → (value: BOOLEAN): TEXT + const ret = returnOf("std::boolean::as_text", [ + {value: {__typename: "LiteralValue", value: true}}, + ]); + expect(ret).toEqual({input: "text"}); + }); + + it("resolves a BOOLEAN return to a boolean input", () => { + // std::boolean::from_number → (value: NUMBER): BOOLEAN + const ret = returnOf("std::boolean::from_number", [ + {value: {__typename: "LiteralValue", value: 1}}, + ]); + expect(ret).toEqual({input: "boolean"}); + }); + + it("resolves an object return (HTTP_RESPONSE) to a data input with its properties", () => { + // http::request::send → (...): HTTP_RESPONSE + const ret = returnOf("http::request::send", [ + {value: {__typename: "LiteralValue", value: "GET"}}, + {value: {__typename: "LiteralValue", value: "/x"}}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + ]); + expect(ret.input).toBe("data"); + const dataRet = ret as {properties: Record; required: string[]}; + expect(Object.keys(dataRet.properties)).toEqual( + expect.arrayContaining(["payload", "headers", "http_status_code"]), + ); + expect(dataRet.required).toEqual( + expect.arrayContaining(["payload", "headers", "http_status_code"]), + ); + // The return type describes an output, so it carries no input + // suggestions — at any nesting level. + expectNoSuggestionsAnywhere(ret); + }); + + it("instantiates a generic return type from the supplied arguments", () => { + // std::list::at → (list: LIST, index: NUMBER): T + // A list of numbers binds T = NUMBER, so the return is a number input. + const ret = returnOf("std::list::at", [ + {value: {__typename: "LiteralValue", value: [10, 20, 30]}}, + {value: {__typename: "LiteralValue", value: 0}}, + ]); + expect(ret).toEqual({input: "number"}); + }); + + it("returns a generic input for a void signature and no nodeId at the flow level", () => { + const result = getSignatureSchema( + singleNode("std::boolean::as_number", [ + {value: {__typename: "LiteralValue", value: true}}, + ]), + DATA_TYPES, + FUNCTION_SIGNATURES, + ); + // Flow-level probe: no target node, flow signature is `(): void`. + expect(result.nodeId).toBeUndefined(); + expect(result.return).toEqual({input: "generic"}); + }); + + it("never carries suggestions, whatever the return type", () => { + // Primitive, object, list and generic returns all stay suggestion-free. + expectNoSuggestionsAnywhere( + returnOf("std::boolean::as_number", [ + {value: {__typename: "LiteralValue", value: true}}, + ]), + ); + expectNoSuggestionsAnywhere( + returnOf("std::list::at", [ + {value: {__typename: "LiteralValue", value: [1, 2, 3]}}, + {value: {__typename: "LiteralValue", value: 0}}, + ]), + ); + expectNoSuggestionsAnywhere( + returnOf("http::request::send", [ + {value: {__typename: "LiteralValue", value: "GET"}}, + {value: {__typename: "LiteralValue", value: "/x"}}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + ]), + ); + }); + }); + }) \ No newline at end of file From 865f47f4aa2db63892b25b3ad4a093607ddd7474 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Thu, 16 Jul 2026 13:47:34 +0200 Subject: [PATCH 3/3] feat: add type property to input schema for better type representation --- src/util/schema.util.ts | 29 ++++++++++++++++++++++------- test/schema/schema.test.ts | 10 +++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index af7e034..51dd1e0 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -19,6 +19,12 @@ import {getSubFlows} from "./subflows.util"; export interface Input { /** The type of input (string representation) */ input?: string; + /** + * The underlying TypeScript type rendered as a string + * (e.g. "NUMBER", "LIST", "string | undefined"), as produced by the + * type checker for the type this schema node was generated from. + */ + type?: string; /** Array of suggested values (functions, references, or literals) */ suggestions?: (NodeFunction | ReferenceValue | LiteralValue | SubFlowValue)[]; } @@ -148,6 +154,10 @@ export const getSchema = ( } } + // The raw TypeScript type as a string, carried on every schema node so the + // consumer knows the concrete type each input was derived from. + const type = checker.typeToString(parameterType); + // Suggestions are filtered by what the surrounding function accepts, not by // the narrower type a current value happens to narrow the node-side to. // Example: `(value: T)` with a current boolean literal must still surface @@ -188,7 +198,7 @@ export const getSchema = ( ) if (nonNullish.length === 1) { const baseSchema = getSchema(checker, node, nonNullish[0], functionDeclarations, functions, false, undefined, visited, recursionCache) - return {...baseSchema, ...combinedSuggestions} + return {...baseSchema, type, ...combinedSuggestions} } } @@ -196,25 +206,25 @@ export const getSchema = ( // so it must be detected before the primitive-literal-union check below; otherwise // `boolean` (and `true | false`) would incorrectly surface as a select. if (isBoolean(parameterType)) { - return {input: "boolean", ...combinedSuggestions}; + return {input: "boolean", type, ...combinedSuggestions}; } // Check primitive literal union first (e.g., "a" | "b" | "c") or a single // string/number literal (e.g., "GET"). A bare literal has only one allowed // value, so it should still surface as a select rather than a free-form text/number input. if (isPrimitiveLiteralUnion(parameterType) || isStringOrNumberLiteral(parameterType)) { - return {input: "select", ...combinedSuggestions}; + return {input: "select", type, ...combinedSuggestions}; } if (isNumber(parameterType)) { - return {input: "number", ...combinedSuggestions}; + return {input: "number", type, ...combinedSuggestions}; } if (isString(parameterType)) { - return {input: "text", ...combinedSuggestions}; + return {input: "text", type, ...combinedSuggestions}; } // Check if type has call signatures (is callable/sub-flow) if (isSubFlow(parameterType)) { - return {input: "sub-flow", ...combinedSuggestions}; + return {input: "sub-flow", type, ...combinedSuggestions}; } // Handle array and tuple types @@ -233,6 +243,7 @@ export const getSchema = ( return { input: "list", + type, items: itemSchemas, ...combinedSuggestions, }; @@ -252,7 +263,7 @@ export const getSchema = ( (visited.size >= MAX_SCHEMA_DEPTH && isRecursiveType(parameterType, checker, recursionCache)) ) { - return {input: "data", ...combinedSuggestions}; + return {input: "data", type, ...combinedSuggestions}; } visited.add(parameterType); @@ -304,6 +315,7 @@ export const getSchema = ( return { input: "data", + type, properties, required, ...combinedSuggestions, @@ -314,6 +326,7 @@ export const getSchema = ( // suggestions (e.g. references in scope) so the UI is never silently empty. return { input: "generic", + type, ...combinedSuggestions, }; }; @@ -435,6 +448,7 @@ const liftGenericIfValued = (schema: Schema, valueProvided: boolean): Schema => if (!valueProvided || schema.input !== "generic") return schema; return { input: "data", + ...(schema.type ? {type: schema.type} : {}), properties: {}, required: [], ...(schema.suggestions ? {suggestions: schema.suggestions} : {}), @@ -464,6 +478,7 @@ const demoteSelect = (schema: Schema): Schema => { : "text"; return { input: target, + ...(schema.type ? {type: schema.type} : {}), ...(suggestions.length > 0 ? {suggestions} : {}), }; }; diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index e657868..4bbdc5f 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1100,7 +1100,7 @@ describe("Schema", () => { const ret = returnOf("std::boolean::as_number", [ {value: {__typename: "LiteralValue", value: true}}, ]); - expect(ret).toEqual({input: "number"}); + expect(ret).toEqual({input: "number", type: "number"}); }); it("resolves a TEXT return to a text input", () => { @@ -1108,7 +1108,7 @@ describe("Schema", () => { const ret = returnOf("std::boolean::as_text", [ {value: {__typename: "LiteralValue", value: true}}, ]); - expect(ret).toEqual({input: "text"}); + expect(ret).toEqual({input: "text", type: "string"}); }); it("resolves a BOOLEAN return to a boolean input", () => { @@ -1116,7 +1116,7 @@ describe("Schema", () => { const ret = returnOf("std::boolean::from_number", [ {value: {__typename: "LiteralValue", value: 1}}, ]); - expect(ret).toEqual({input: "boolean"}); + expect(ret).toEqual({input: "boolean", type: "boolean"}); }); it("resolves an object return (HTTP_RESPONSE) to a data input with its properties", () => { @@ -1151,7 +1151,7 @@ describe("Schema", () => { {value: {__typename: "LiteralValue", value: [10, 20, 30]}}, {value: {__typename: "LiteralValue", value: 0}}, ]); - expect(ret).toEqual({input: "number"}); + expect(ret).toEqual({input: "number", type: "number"}); }); it("returns a generic input for a void signature and no nodeId at the flow level", () => { @@ -1164,7 +1164,7 @@ describe("Schema", () => { ); // Flow-level probe: no target node, flow signature is `(): void`. expect(result.nodeId).toBeUndefined(); - expect(result.return).toEqual({input: "generic"}); + expect(result.return).toEqual({input: "generic", type: "void"}); }); it("never carries suggestions, whatever the return type", () => {