diff --git a/src/extraction/getFlowSchemas.ts b/src/extraction/getFlowSchemas.ts index 90f9877..b42e4a6 100644 --- a/src/extraction/getFlowSchemas.ts +++ b/src/extraction/getFlowSchemas.ts @@ -15,8 +15,7 @@ import type {JsonSchema} from "../util/jsonSchema.util" * The input/output JSON schemas computed for a callable signature. * * Both are derived from the *real* signature — the flow's own signature for the - * flow, or the callback parameter type (e.g. `(item: T) => void`) for a - * sub-flow. + * flow, or the generated lambda value (`(...p) => { …body… }`) for a sub-flow. * * `inputSchema` is an `object` schema whose `properties` hold one entry per * signature parameter, keyed by the parameter name (non-optional parameters are @@ -51,11 +50,13 @@ export type SchematizedFlow = Flow & SignatureSchemas * The schemas are computed from the real signatures, resolved through the same * virtual TypeScript program that {@link generateFlowSourceCode} builds: * - For the flow, the schemas come from the flow's own `signature`. - * - For a sub-flow, the schemas come from the callback parameter type of the - * surrounding function (e.g. `std::list::for_each`'s `consumer` parameter is - * typed `(item: T) => void`). Because the type is resolved from the concrete - * call, generic type parameters (the `T` above) are already instantiated with - * the real element type at that node. + * - For a sub-flow, the schemas come from the *value* generated at that argument + * position — the lambda `(...p) => { …body… }` emitted by + * {@link generateFlowSourceCode} — not from the callback parameter the + * surrounding function declares. The input carries the real (already + * instantiated) argument types the lambda receives, and the output is what the + * sub-flow body actually returns (e.g. via a `std::control::return` node), + * rather than the `void` the surrounding function merely expects. * * The actual JSON Schema generation is delegated to `ts-json-schema-generator` * (see {@link generateJsonSchemas}); this function only resolves the real @@ -144,8 +145,13 @@ const collectFlowTypeExpressions = ( /** * Collects the input/output type expressions of every sub-flow parameter in the - * flow. Each callback type is read from the node's concrete call expression, so - * generic type parameters are already substituted with the real argument types. + * flow. The schema is read from the *value* generated for the sub-flow — i.e. the + * lambda `(...p) => { …body… }` that {@link generateFlowSourceCode} emits at that + * argument position — not from the callback *parameter* the surrounding function + * declares. This way the output reflects what the sub-flow body actually returns + * (e.g. a `std::control::return` node), rather than the `void` the function + * merely expects, while the input still carries the real argument types (generics + * are already instantiated because the lambda is contextually typed by the call). */ const collectSubFlowTypeExpressions = ( checker: ts.TypeChecker, @@ -161,26 +167,23 @@ const collectSubFlowTypeExpressions = ( if (!params?.some(isSubFlowParameter)) continue const callExpression = nodeCallExpression(constants, node) - const resolvedSignature = callExpression - ? checker.getResolvedSignature(callExpression) - : undefined - if (!resolvedSignature) continue + if (!callExpression) continue params.forEach((param, index) => { if (!isSubFlowParameter(param)) return - const callbackSignature = resolveCallbackSignature( - checker, - resolvedSignature, - callExpression!, - index, - ) - if (!callbackSignature) return + // The generated arguments are positional, so the parameter index maps + // straight onto the call argument holding the sub-flow lambda value. + const argument = callExpression.arguments[index] + const valueSignature = argument + ? checker.getTypeAtLocation(argument).getCallSignatures()[0] + : undefined + if (!valueSignature) return - const {input, output} = signatureToTypeExpressions( + const {input, output} = subFlowValueToTypeExpressions( checker, - callbackSignature, - callExpression!, + valueSignature, + argument!, ) typeExpressions[subFlowAlias(node.id, index, "input")] = input typeExpressions[subFlowAlias(node.id, index, "output")] = output @@ -191,24 +194,95 @@ const collectSubFlowTypeExpressions = ( } /** - * Resolves the call signature of the callback passed at the given parameter - * position — e.g. the `(item: T) => void` consumer of `std::list::for_each`, - * with `T` already instantiated by the surrounding call. + * Turns the call signature of a generated sub-flow lambda into a pair of type + * expressions. The lambda is emitted with a single rest parameter (`(...p) => …`) + * whose contextually-inferred type is a labelled tuple of the callback arguments; + * that tuple is expanded so the input object is keyed by the argument names (e.g. + * `item`) instead of the synthetic rest name. The output is the signature's + * return type — the value the sub-flow body actually yields. */ -const resolveCallbackSignature = ( +const subFlowValueToTypeExpressions = ( checker: ts.TypeChecker, - resolvedSignature: ts.Signature, - callExpression: ts.CallExpression, - parameterIndex: number, -): ts.Signature | undefined => { - const parameterSymbol = resolvedSignature.parameters[parameterIndex] - if (!parameterSymbol) return undefined + signature: ts.Signature, + location: ts.Node, +): {input: string; output: string} => { + const printType = (type: ts.Type): string => + checker.typeToString(type, location, ts.TypeFormatFlags.NoTruncation) - const callbackType = checker.getTypeOfSymbolAtLocation( - parameterSymbol, - callExpression, - ) - return callbackType.getCallSignatures()[0] + const members = subFlowInputMembers(checker, signature, location) + + return { + input: `{ ${members.join("; ")} }`, + output: printType(checker.getReturnTypeOfSignature(signature)), + } +} + +/** + * Builds the input members for a sub-flow lambda. Expands the single rest tuple + * parameter into one member per labelled element; falls back to the raw signature + * parameters when the parameter is not a tuple (e.g. an unresolved `any[]`). + */ +const subFlowInputMembers = ( + checker: ts.TypeChecker, + signature: ts.Signature, + location: ts.Node, +): string[] => { + const printType = (type: ts.Type): string => + checker.typeToString(type, location, ts.TypeFormatFlags.NoTruncation) + + const parameters = signature.getParameters() + if (parameters.length === 1) { + const restType = checker.getTypeOfSymbolAtLocation( + parameters[0], + parameters[0].valueDeclaration ?? location, + ) + const tupleMembers = expandTupleMembers(checker, restType, location) + if (tupleMembers) return tupleMembers + } + + return parameters.map((parameterSymbol) => { + const parameterType = checker.getTypeOfSymbolAtLocation( + parameterSymbol, + parameterSymbol.valueDeclaration ?? location, + ) + const optional = isOptionalParameter(parameterSymbol) ? "?" : "" + return `${parameterSymbol.getName()}${optional}: ${printType(parameterType)}` + }) +} + +/** + * Expands a labelled tuple type into one member expression per element, keyed by + * the element label (falling back to `arg` for unlabelled elements). Returns + * `undefined` when the type is not a tuple. + */ +const expandTupleMembers = ( + checker: ts.TypeChecker, + type: ts.Type, + location: ts.Node, +): string[] | undefined => { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType | undefined + if (!target || (target.objectFlags & ts.ObjectFlags.Tuple) === 0) { + return undefined + } + + const elementTypes = checker.getTypeArguments(reference) + return elementTypes.map((elementType, index) => { + const declaration = target.labeledElementDeclarations?.[index] + const name = + declaration?.name && ts.isIdentifier(declaration.name) + ? declaration.name.text + : `arg${index}` + const optional = + (target.elementFlags[index] & ts.ElementFlags.Optional) !== 0 + ? "?" + : "" + return `${name}${optional}: ${checker.typeToString( + elementType, + location, + ts.TypeFormatFlags.NoTruncation, + )}` + }) } /** diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index dfae2ff..fa2441f 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -616,10 +616,10 @@ function getCustomInput( * Checks whether a type is the FILE data type. * * A type only counts as FILE when it is *both* named `FILE` and shaped like FILE - * (`{ contentType: M; valueType: 'base64'; value: string }`) — the name alone - * could be an unrelated alias, and the shape alone could be a coincidental - * object literal. The `valueType: 'base64'` literal is the distinguishing part - * of the structure. + * (`{ contentType: M; fileName: string; valueType: 'base64'; value: string }`) — + * the name alone could be an unrelated alias, and the shape alone could be a + * coincidental object literal. The `valueType: 'base64'` literal is the + * distinguishing part of the structure. * * @param checker - The type checker * @param type - The type to check @@ -631,11 +631,17 @@ function isFileType(checker: ts.TypeChecker, type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const properties = checker.getPropertiesOfType(type); - if (properties.length !== 3) return false; + if (properties.length !== 4) return false; const byName = new Map(properties.map((p) => [p.name, p])); const valueType = byName.get("valueType"); - if (!byName.has("contentType") || !byName.has("value") || !valueType) return false; + if ( + !byName.has("contentType") || + !byName.has("fileName") || + !byName.has("value") || + !valueType + ) + return false; const declaration = valueType.valueDeclaration ?? valueType.declarations?.[0]; if (!declaration) return false; diff --git a/src/utils.ts b/src/utils.ts index 9a52ee5..4d82979 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -210,6 +210,15 @@ export function generateFlowSourceCode( } if (val.__typename === "SubFlowValue") { const wrapper = val as SubFlowValue; + // Direct mapping: the sub-flow *is* an existing function, with no + // node tree of its own (no startingNodeId). Emit the function + // reference itself as the value so its own signature drives the + // sub-flow's I/O — e.g. mapping `std::math::add` yields + // `(a, b) => NUMBER` rather than an empty `(...p) => {}` lambda. + if (!wrapper.startingNodeId && wrapper.functionDefinition?.identifier) { + const funcName = `fn_${wrapper.functionDefinition.identifier.replace(/::/g, '_')}`; + return `/* @pos ${id} ${index} */ ${funcName}`; + } const lambdaArgName = `p_${sanitizeId(id as string)}_${index}`; const subTreeCode = generateNodeCode(wrapper.startingNodeId || wrapper.functionDefinition?.id!, indent + " "); return `/* @pos ${id} ${index} */ (...${lambdaArgName}) => {\n${subTreeCode}${indent}}`; diff --git a/test/data.ts b/test/data.ts index 1d5ca57..8e9a292 100644 --- a/test/data.ts +++ b/test/data.ts @@ -1529,7 +1529,7 @@ export const DATA_TYPES: DataType[] = [ "genericKeys": [ "M extends TEXT" ], - "type": "{ contentType: M; valueType: 'base64'; value: string }", + "type": "{ contentType: M; fileName: string; valueType: 'base64'; value: string }", "definitionSource": "", "version": "0.0.34", "name": [ diff --git a/test/flowSchemas.test.ts b/test/flowSchemas.test.ts index d22fdae..e921870 100644 --- a/test/flowSchemas.test.ts +++ b/test/flowSchemas.test.ts @@ -203,14 +203,18 @@ describe("getFlowSchemas", () => { const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); const predicate = subFlowValueOf(result, LIST_NODE_ID, 1); - // PREDICATE = (item: T) => BOOLEAN, with T resolved to NUMBER. + // Input is keyed by the callback parameter name `item`, with T resolved + // to NUMBER. expect(predicate.inputSchema).toEqual({ type: "object", additionalProperties: false, properties: {item: {type: "number"}}, required: ["item"], }); - expect(predicate.outputSchema).toEqual({type: "boolean"}); + // Output is what the sub-flow value actually returns, not the BOOLEAN the + // filter's PREDICATE slot expects: the body only computes a value without + // a `std::control::return` node, so the generated lambda returns void. + expect(predicate.outputSchema).toEqual({type: "null"}); }); it("infers the map transform output from the sub-flow's return node", () => { @@ -282,14 +286,16 @@ describe("getFlowSchemas", () => { const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); const predicate = subFlowValueOf(result, LIST_NODE_ID, 1); - // The schemas come from the predicate slot type PREDICATE with T - // resolved to NUMBER — same as with a starting node. + // Input carries the resolved item type (T → NUMBER) the sub-flow value + // receives — same as with a starting node. expect(predicate.inputSchema).toEqual({ type: "object", additionalProperties: false, - properties: {item: {type: "number"}}, - required: ["item"], + properties: {value: {type: "number"}}, + required: ["value"], }); + // The directly-mapped function is called without a `std::control::return`, + // so the generated lambda returns void rather than the expected BOOLEAN. expect(predicate.outputSchema).toEqual({type: "boolean"}); // The original SubFlowValue fields are preserved. @@ -297,6 +303,44 @@ describe("getFlowSchemas", () => { expect(predicate.startingNodeId).toBeUndefined(); }); + it("accepts any sub-flow value via an accept-all callback parameter", () => { + // A function definition whose sub-flow parameter accepts *any* sub-flow + // value: (...args: any[]) => R matches every generated lambda. Defined + // inline here (not in data.ts). Feed std::math::add as a direct mapping. + const acceptAll = { + __typename: "FunctionDefinition" as const, + identifier: "std::control::execute", + signature: "(sub_flow: (...args: any[]) => any): void", + }; + + const flow = flowWithNodes([ + node(LIST_NODE_ID, "std::control::execute", [ + subFlowCalling("std::number::add"), + ]), + ]); + + const result = getFlowSchemas( + flow, + [...FUNCTION_SIGNATURES, acceptAll], + DATA_TYPES, + ); + const subFlow = subFlowValueOf(result, LIST_NODE_ID, 0); + + // The direct-mapped function *is* the sub-flow value, so its own signature + // `(first: NUMBER, second: NUMBER): NUMBER` drives the I/O — regardless of + // the `void` the accept-all parameter merely expects. + expect(subFlow.inputSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: { + first: {type: "number"}, + second: {type: "number"}, + }, + required: ["first", "second"], + }); + expect(subFlow.outputSchema).toEqual({type: "number"}); + }); + it("leaves nodes without sub-flow parameters untouched", () => { const flow = flowWithNodes([ node(LIST_NODE_ID, "std::number::add", [literal(1), literal(2)]), diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 3da17e1..08af4f0 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1467,39 +1467,42 @@ describe("Schema", () => { it("resolves to a data input carrying the mimetype", () => { expect(getTypeSchema("FILE<'image/png'>", DATA_TYPES)).toEqual({ input: "data", - type: '{ contentType: "image/png"; valueType: "base64"; value: string; }', + type: '{ contentType: "image/png"; fileName: string; valueType: "base64"; value: string; }', properties: { contentType: { input: "select", type: '"image/png"' }, + fileName: { input: "text", type: "string" }, valueType: { input: "select", type: '"base64"' }, value: { input: "text", type: "string" }, }, - required: ["contentType", "valueType", "value"], + required: ["contentType", "fileName", "valueType", "value"], }); }); it("surfaces a string contentType for an unconstrained FILE", () => { expect(getTypeSchema("FILE", DATA_TYPES)).toEqual({ input: "data", - type: '{ contentType: string; valueType: "base64"; value: string; }', + type: '{ contentType: string; fileName: string; valueType: "base64"; value: string; }', properties: { contentType: { input: "text", type: "string" }, + fileName: { input: "text", type: "string" }, valueType: { input: "select", type: '"base64"' }, value: { input: "text", type: "string" }, }, - required: ["contentType", "valueType", "value"], + required: ["contentType", "fileName", "valueType", "value"], }); }); it("surfaces the raw contentType when the constraint is not a valid MIME type", () => { expect(getTypeSchema("FILE<'not-a-mimetype'>", DATA_TYPES)).toEqual({ input: "data", - type: '{ contentType: "not-a-mimetype"; valueType: "base64"; value: string; }', + type: '{ contentType: "not-a-mimetype"; fileName: string; valueType: "base64"; value: string; }', properties: { contentType: { input: "select", type: '"not-a-mimetype"' }, + fileName: { input: "text", type: "string" }, valueType: { input: "select", type: '"base64"' }, value: { input: "text", type: "string" }, }, - required: ["contentType", "valueType", "value"], + required: ["contentType", "fileName", "valueType", "value"], }); }); @@ -1511,7 +1514,7 @@ describe("Schema", () => { expect(object.input).toBe("data"); expect(object.properties.avatar).toEqual({ input: "file", - type: '{ contentType: "image/png"; valueType: "base64"; value: string; }', + type: '{ contentType: "image/png"; fileName: string; valueType: "base64"; value: string; }', mimetype: "image/png", }); }); @@ -1545,7 +1548,7 @@ describe("Schema", () => { { "input": "file", "mimetype": "image/png", - "type": '{ contentType: "image/png"; valueType: "base64"; value: string; }', + "type": '{ contentType: "image/png"; fileName: string; valueType: "base64"; value: string; }', }, ], type: '(number | FILE<"image/png">)[]'