Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 112 additions & 38 deletions src/extraction/getFlowSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<i>` 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,
)}`
})
}

/**
Expand Down
18 changes: 12 additions & 6 deletions src/util/schema.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`;
Expand Down
2 changes: 1 addition & 1 deletion test/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
56 changes: 50 additions & 6 deletions test/flowSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,18 @@ describe("getFlowSchemas", () => {
const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES);
const predicate = subFlowValueOf(result, LIST_NODE_ID, 1);

// PREDICATE<T> = (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", () => {
Expand Down Expand Up @@ -282,21 +286,61 @@ 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<T> 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.
expect(predicate.functionDefinition?.identifier).toBe("std::boolean::from_number");
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)]),
Expand Down
Loading