From 33994b891a0471efdf1db03fa1fe0f6e6917e541 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Thu, 15 Oct 2026 18:25:39 +0200 Subject: [PATCH] feat: improve flow schema generation and inference handling for unfilled parameters --- src/extraction/getFlowSchemas.ts | 61 ++++++++++++++++++++++--- src/utils.ts | 13 ++---- src/validation/getFlowValidation.ts | 2 +- test/flowCast.test.ts | 10 +++-- test/flowSchemas.test.ts | 69 ++++++++++++++++++++++++++--- 5 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/extraction/getFlowSchemas.ts b/src/extraction/getFlowSchemas.ts index b42e4a6..e332513 100644 --- a/src/extraction/getFlowSchemas.ts +++ b/src/extraction/getFlowSchemas.ts @@ -49,7 +49,11 @@ 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 the flow, the schemas come from the flow's own `signature`, resolved at + * the generated `flow()` call so the flow settings instantiate its + * type parameters — a generic link between input and output (as a REST flow's + * `(input_schema: T, …): REST_ADAPTER_INPUT`) therefore + * carries the shape the settings actually supply. * - 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 @@ -119,9 +123,23 @@ const subFlowAlias = ( ): string => `Node_${sanitizeId(nodeId!)}_${parameterIndex}_${kind}` /** - * Collects the flow's own input/output type expressions from its declared - * signature (`declare function flow(...): ...` in the generated source). A flow - * without a signature contributes nothing and falls back to the empty schemas. + * Collects the flow's own input/output type expressions from its signature. + * + * The signature is read from the generated `flow(...)` *call* — the one + * {@link generateFlowSourceCode} emits with the flow's settings as positional + * arguments — rather than from the `declare function flow(...)` declaration, so + * the settings instantiate the signature's type parameters. This is what makes a + * generic link between input and output hold: a REST flow declared as + * `(input_schema: T, …): REST_ADAPTER_INPUT` has its payload + * resolved to the shape the `input_schema` setting actually carries, instead of + * collapsing to the `TYPE` bound. The same resolution already drives the sub-flow + * schemas (whose lambdas are contextually typed by their call), which is why they + * reflect the settings while the flow's own schemas did not. + * + * Falls back to the declaration's signature when no call was generated (no flow) + * — a flow whose settings are missing or unfilled simply leaves each type + * parameter at its constraint. A flow without a signature contributes nothing and + * falls back to the empty schemas. */ const collectFlowTypeExpressions = ( checker: ts.TypeChecker, @@ -132,17 +150,48 @@ const collectFlowTypeExpressions = ( .find((declaration) => declaration.name?.getText() === "flow") if (!flowDeclaration) return {} - const flowSignature = checker.getSignatureFromDeclaration(flowDeclaration) + const flowCall = findFlowCallExpression(sourceFile) + const resolvedSignature = flowCall + ? checker.getResolvedSignature(flowCall) + : undefined + const flowSignature = + resolvedSignature ?? checker.getSignatureFromDeclaration(flowDeclaration) if (!flowSignature) return {} const {input, output} = signatureToTypeExpressions( checker, flowSignature, - flowDeclaration, + flowCall ?? flowDeclaration, ) return {[FLOW_INPUT_ALIAS]: input, [FLOW_OUTPUT_ALIAS]: output} } +/** + * Finds the generated `flow()` call, i.e. the initializer of the + * `const flow_ = flow(...)` statement. Traverses the AST because that + * constant lives inside the generated IIFE. + */ +const findFlowCallExpression = ( + sourceFile: ts.SourceFile, +): ts.CallExpression | undefined => { + let flowCall: ts.CallExpression | undefined + + sourceFile.forEachChild(function visit(child) { + if (flowCall) return + const isFlowCall = + ts.isCallExpression(child) && + ts.isIdentifier(child.expression) && + child.expression.text === "flow" + if (isFlowCall) { + flowCall = child as ts.CallExpression + return + } + child.forEachChild(visit) + }) + + return flowCall +} + /** * Collects the input/output type expressions of every sub-flow parameter in the * flow. The schema is read from the *value* generated for the sub-flow — i.e. the diff --git a/src/utils.ts b/src/utils.ts index b224b1b..126b2e6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -331,7 +331,6 @@ export function generateFlowSourceCode( flow?: Flow, functions?: FunctionDefinition[], dataTypes?: DataType[], - isForInference: boolean = false, assertNonNullReferences: boolean = true ): string { const nodes = flow?.nodes?.nodes || []; @@ -430,14 +429,10 @@ export function generateFlowSourceCode( const params = (node.parameters?.nodes as NodeParameter[]) || []; const args = params.map((p, index) => { const val = p.value; - // A parameter without a value: during inference the `{}` placeholder - // is asserted to the cast so an unfilled slot still carries its - // intended type. During validation the `undefined` placeholder stays - // uncast — the cast would silently satisfy the parameter and hide the - // fact that nothing was provided. - const missing = isForInference - ? `/* @pos ${id} ${index} */ ${applyCast("{}", p.cast)}` - : `/* @pos ${id} ${index} */ undefined`; + // A parameter without a value is emitted as an uncast `undefined` + // placeholder: applying the cast would silently satisfy the parameter + // and hide the fact that nothing was provided. + const missing = `/* @pos ${id} ${index} */ undefined`; if (!val) return missing; if (val.__typename === "ReferenceValue") { return `/* @pos ${id} ${index} */ ${applyCast(renderReference(val as ReferenceValue), p.cast)}`; diff --git a/src/validation/getFlowValidation.ts b/src/validation/getFlowValidation.ts index f3635c9..82f32af 100644 --- a/src/validation/getFlowValidation.ts +++ b/src/validation/getFlowValidation.ts @@ -140,7 +140,7 @@ export const getFlowValidation = ( // Validation keeps reference nullability (assertNonNullReferences = false) so that a // possibly-null reference into a non-null parameter surfaces as a diagnostic, which is // then downgraded to a warning below instead of being silently suppressed by a `!`. - const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false, false); + const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false); // 3. Virtual TypeScript Compilation const fileName = "index.ts"; diff --git a/test/flowCast.test.ts b/test/flowCast.test.ts index 519fa16..120fd6c 100644 --- a/test/flowCast.test.ts +++ b/test/flowCast.test.ts @@ -156,22 +156,24 @@ describe("cast handling in the generated flow source", () => { FUNCTION_SIGNATURES, DATA_TYPES, false, - false, ); expect(source).toContain("*/ undefined"); expect(source).not.toContain("(undefined) as"); }); - it("asserts an unfilled parameter to its cast during inference", () => { + it("leaves an unfilled parameter uncast for inference and schemas too", () => { + // The unfilled slot stays an uncast `undefined` placeholder regardless of + // how references are rendered: applying the cast would make an empty slot + // look filled, so an unfilled parameter contributes no type anywhere. const source = generateFlowSourceCode( firstOf({value: null, cast: "LIST"}), FUNCTION_SIGNATURES, DATA_TYPES, - true, ); - expect(source).toContain("({}) as unknown as LIST"); + expect(source).toContain("*/ undefined"); + expect(source).not.toContain("as unknown as LIST"); }); it("asserts a flow setting value to its cast", () => { diff --git a/test/flowSchemas.test.ts b/test/flowSchemas.test.ts index 7796bb9..27925aa 100644 --- a/test/flowSchemas.test.ts +++ b/test/flowSchemas.test.ts @@ -111,25 +111,82 @@ describe("getFlowSchemas", () => { const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); - // TYPE is `any` underneath, so it maps to the open object schema; the - // concrete parameters resolve fully. + // The flow carries no settings, so there is nothing to bind T to and it + // stays at its `TYPE` bound — the open object schema. The concrete + // parameters resolve fully. expect(result?.inputSchema.properties?.input_schema) - .toEqual({}); + .toEqual({type: "object", additionalProperties: false}); expect(result?.inputSchema.properties?.httpURL).toEqual({type: "string"}); expect((result?.inputSchema.properties?.httpMethod as JsonSchema).enum) .toEqual(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]); expect(result?.inputSchema.required) .toEqual(["input_schema", "httpURL", "httpMethod"]); - // REST_ADAPTER_INPUT resolves structurally; the TYPE payload maps to - // the open object schema while the concrete fields become object schemas. + // REST_ADAPTER_INPUT resolves structurally; with T unbound its + // payload is the same open object schema as the input slot, while the + // concrete fields become object schemas. expect(result?.outputSchema.type).toBe("object"); expect(result?.outputSchema.properties?.payload) - .toEqual({}); + .toEqual({type: "object", additionalProperties: false}); expect(result?.outputSchema.required) .toEqual(["payload", "headers", "query_params", "path_params"]); }); + it("instantiates the flow's generic schemas from its settings", () => { + // The REST trigger links input to output generically: whatever + // `input_schema` binds T to is what REST_ADAPTER_INPUT echoes as its + // payload. The settings are the values that binding comes from, so they + // have to reach the flow's own schemas — not just the sub-flows'. + const flow: Flow = { + ...flowWithNodes( + [], + "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", + ), + settings: { + nodes: [ + {value: {name: "text", age: 5}}, + {value: "/users"}, + {value: "GET"}, + ], + }, + }; + + const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); + + const boundSchema: JsonSchema = { + type: "object", + additionalProperties: false, + properties: {name: {type: "string"}, age: {type: "number"}}, + required: ["name", "age"], + }; + + // T is bound from the input_schema setting, so the input slot carries the + // concrete shape rather than the TYPE bound. + expect(result?.inputSchema.properties?.input_schema).toEqual(boundSchema); + + // …and the generically linked payload echoes that same shape. + expect(result?.outputSchema.properties?.payload).toEqual(boundSchema); + + // The remaining REST fields stay open objects. + expect(result?.outputSchema.required) + .toEqual(["payload", "headers", "query_params", "path_params"]); + }); + + it("falls back to the parameter bounds when a setting carries no value", () => { + // An unfilled setting binds nothing: T stays at its TYPE bound instead of + // collapsing to the `undefined` placeholder the slot renders as. + const flow: Flow = { + ...flowWithNodes([], "(input_schema: T): REST_ADAPTER_INPUT"), + settings: {nodes: [{}]}, + }; + + const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); + + expect(result?.inputSchema.properties?.input_schema) + .toEqual({type: "object", additionalProperties: false}); + expect(result?.outputSchema.type).toBe("object"); + }); + it("maps the DATE data type to its underlying number type", () => { const flow = flowWithNodes([], "(created: DATE): void");