From 08cee1c530d5169de883153741f223e0cac691e2 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Thu, 17 Sep 2026 00:11:14 +0200 Subject: [PATCH 1/7] feat: update generic type handling and versioning in data schema --- test/data.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/data.ts b/test/data.ts index 019bafc..da96f0f 100644 --- a/test/data.ts +++ b/test/data.ts @@ -1374,12 +1374,10 @@ export const DATA_TYPES: DataType[] = [ "createdAt": "2026-06-19T15:33:16Z", "updatedAt": "2026-06-19T15:34:59Z", "identifier": "TYPE", - "genericKeys": [ - "T" - ], - "type": "T", + "genericKeys": [], + "type": "any", "definitionSource": "", - "version": "0.0.0", + "version": "0.0.37", "name": [ { "__typename": "Translation", @@ -1398,7 +1396,7 @@ export const DATA_TYPES: DataType[] = [ { "__typename": "Translation", "code": "en-US", - "content": "Type of ${T}" + "content": "Type" } ], "runtime": { From 7c812e5ba716146a65d735c4e8bf566f16715c17 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 18 Sep 2026 22:19:36 +0200 Subject: [PATCH 2/7] feat: refine type branding logic for custom input identifiers and handle 'any' type correctly --- src/utils.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 55168b4..2c57bcd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -141,7 +141,18 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s // Branding with an empty intersection keeps the alias name on the resolved // type — staying mutually assignable with the base type — so the schema // layer can recover the identifier and surface the mapped input. - const type = isCustomInputIdentifier(dt.identifier) ? `${dt.type} & {}` : dt.type; + // + // `any` is the exception: `any & {}` collapses straight back to `any`, + // dropping the alias. The `any`-typed TYPE is therefore branded as the + // empty object type `{}`, which keeps its alias name on the resolved type + // (surviving nesting like DATE does) while staying a supertype of every + // value — so a `` constraint still binds T to the concrete + // argument, primitive or object alike. + const type = !isCustomInputIdentifier(dt.identifier) + ? dt.type + : dt.type?.trim() === "any" + ? "{}" + : `${dt.type} & {}`; return `type ${dt.identifier}${generics} = ${type};`; }).join("\n"); From da090f92cf135eb1dca75f4db5457d8c6912e6b3 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 18 Sep 2026 22:20:11 +0200 Subject: [PATCH 3/7] feat: introduce TYPE input identifier and enhance handling of declared types in schema generation --- src/util/schema.util.ts | 119 ++++++++++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 6fefaae..e86aaa6 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -29,6 +29,7 @@ import {getSubFlows} from "./subflows.util"; */ export const CUSTOM_INPUT_IDENTIFIERS = { DATE: "date", + TYPE: "type", } as const satisfies Record; /** A data type identifier that maps to a custom input. */ @@ -212,15 +213,13 @@ export interface ListInput extends Input { } /** - * Represents a complex type input with properties and required fields. - * Similar to DataInput but used for type definitions. + * Represents a type input. + * Emitted for the TYPE data type so the UI can render a dedicated type picker + * instead of the plain input its underlying type (`any`) would otherwise + * produce. Like {@link DateInput}, it carries no additional properties. */ export interface TypeInput extends Input { input?: "type"; - /** Record mapping property names to their schemas */ - properties?: Record; - /** Array of required property names */ - required?: string[]; } /** @@ -288,6 +287,11 @@ export const getSchema = ( suggestionType?: ts.Type, visited: Set = new Set(), recursionCache: Map = new Map(), + // The type this schema node is declared as, tracked alongside the concrete + // `parameterType` when they differ because of generic instantiation. Only the + // declared type still carries a type parameter's constraint (e.g. a REST + // trigger's `` payload), which the instantiated type has lost. + declaredType?: ts.Type, ): Schema => { if ((parameterType.flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -301,19 +305,23 @@ 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. Custom-input - // data types (e.g. DATE) are branded as ` & {}` so their alias - // survives detection (see getSharedTypeDeclarations); for those, stringify the - // unbranded base member so the rendered type stays clean ("number", not - // "number & {}"). Every other type drops its top-level alias so the concrete - // structure is rendered rather than the wrapping alias name. - const type = checker.typeToString( - isCustomInputIdentifier(parameterType.aliasSymbol?.getName()) && - parameterType.isIntersection() - ? parameterType.types.find( + // data types are branded so their alias survives detection (see + // getSharedTypeDeclarations): primitive-based ones (e.g. DATE) as + // ` & {}`, from which we stringify the unbranded base member so the + // rendered type stays clean ("number", not "number & {}"); the `any`-based TYPE + // as the empty object `{}`, rendered as the neutral "object". Every other type + // drops its top-level alias so the concrete structure is rendered rather than + // the wrapping alias name. + const isCustom = isCustomInputIdentifier(parameterType.aliasSymbol?.getName()); + const type = isCustom && parameterType.isIntersection() + ? checker.typeToString( + parameterType.types.find( (t) => (t.flags & ts.TypeFlags.Object) === 0 ) ?? parameterType - : {...parameterType, aliasSymbol: undefined} - ); + ) + : isCustom + ? "object" + : checker.typeToString({...parameterType, aliasSymbol: undefined}); // Suggestions are filtered by what the surrounding function accepts, not by // the narrower type a current value happens to narrow the node-side to. @@ -346,6 +354,18 @@ export const getSchema = ( ], } : {}; + // A slot whose *declared* type is a type parameter constrained by a + // custom-input data type (e.g. a REST trigger's `` payload, + // instantiated to a concrete argument) surfaces that custom input. The + // instantiated `type` string is kept so the concrete shape the value bound to + // stays visible (e.g. `{input: "type", type: "number"}`). + if (declaredType) { + const constraintInput = getCustomInputFromConstraint(checker, declaredType); + if (constraintInput) { + return {input: constraintInput, type, ...combinedSuggestions}; + } + } + // Strip undefined and null from unions (e.g. string | undefined | null → string). // Suggestions are collected above from the original type (preserving aliasSymbol literals), // the base schema is determined from the stripped type, then both are merged. @@ -528,9 +548,18 @@ export const getSchema = ( ) : [propertyType]; + // The matching property on the declared type (when tracked), so a + // property whose declared type is a custom-input-constrained type + // parameter is recognised even after generic instantiation erased the + // constraint from the concrete `propertyType` (see getSchema's + // `declaredType` handling). + const declaredPropertyType = declaredType + ? getDeclaredPropertyType(checker, declaredType, property.name) + : undefined; + // Recursively generate schemas for property types const propertySchemas = propertyTypes.map((type) => - getSchema(checker, node, type, functionDeclarations, functions, suggestions, undefined, visited, recursionCache) + getSchema(checker, node, type, functionDeclarations, functions, suggestions, undefined, visited, recursionCache, declaredPropertyType) ); properties[property.name] = @@ -903,6 +932,60 @@ function getCustomInput( return isCustomInputIdentifier(name) ? CUSTOM_INPUT_IDENTIFIERS[name] : undefined; } +/** + * Returns the custom input kind implied by a *declared* type when that type is a + * type parameter constrained by a custom-input data type — or undefined + * otherwise. + * + * Example: a REST trigger `(...): REST_ADAPTER_INPUT`. Once + * the call is resolved (e.g. `REST_ADAPTER_INPUT`), the `payload` + * property's type is the concrete argument (`number`) and has lost the TYPE + * alias, so {@link getCustomInput} can no longer recover it. The signature's + * *declared* return type, however, still carries the type parameter `T` whose + * constraint is TYPE — so the custom input is recovered from there while the + * concrete instantiated type is still rendered as the schema's `type` (see the + * `declaredType` threading in {@link getSchema}). + */ +function getCustomInputFromConstraint( + checker: ts.TypeChecker, + type: ts.Type, +): (typeof CUSTOM_INPUT_IDENTIFIERS)[CustomInputIdentifier] | undefined { + if ((type.flags & ts.TypeFlags.TypeParameter) === 0) return undefined; + + const typeParamDecl = type.symbol?.declarations?.[0]; + if ( + !typeParamDecl || + !ts.isTypeParameterDeclaration(typeParamDecl) || + !typeParamDecl.constraint + ) { + return undefined; + } + + // getTypeFromTypeNode (not getBaseConstraintOfType) so the constraint's + // alias name survives — the same reason the type-parameter branch of + // getSchema resolves constraints this way. + return getCustomInput(checker.getTypeFromTypeNode(typeParamDecl.constraint)); +} + +/** + * Resolves the type of a named property on the declared type, or undefined when + * it has no such property. Used to walk the declared type in step with the + * concrete type so a custom-input-constrained type parameter (see + * {@link getCustomInputFromConstraint}) can still be recovered from the + * declaration after instantiation. + */ +function getDeclaredPropertyType( + checker: ts.TypeChecker, + declaredType: ts.Type, + propertyName: string, +): ts.Type | undefined { + const symbol = checker.getPropertyOfType(declaredType, propertyName); + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]; + return declaration + ? checker.getTypeOfSymbolAtLocation(symbol!, declaration) + : undefined; +} + /** * Checks whether a type is the FILE data type. * From 72f3c6105c1cc3551f753cbb39084a1a29434018 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 18 Sep 2026 22:20:27 +0200 Subject: [PATCH 4/7] feat: refine TYPE input handling to support bounded generics and enhance schema resolution for nested structures --- test/schema/schema.test.ts | 46 ++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 924beaa..2371414 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -80,7 +80,7 @@ describe("Schema", () => { "id": "gid://sagittarius/Flow/1", "createdAt": "2026-06-19T15:34:11Z", "name": "Test_v1", - "signature": "(input_schema: TYPE, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", + "signature": "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", "nodes": { "__typename": "NodeFunctionConnection", "nodes": [ @@ -1318,12 +1318,14 @@ describe("Schema", () => { // 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. + // REST trigger, whose type parameter is bounded by the (non-generic) + // TYPE data type and whose payload echoes the bound argument: + // (input_schema: T, ...): 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", + "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", settings: { nodes: [ {value: inputSchema}, @@ -1368,12 +1370,12 @@ describe("Schema", () => { ]), ); - // T is bound from the input_schema setting ({name: TEXT}), so the - // payload keeps that concrete shape. + // T is bound from the input_schema setting ({name: TEXT}). The + // parameter is declared as `T extends TYPE`, so the resolved payload + // is a TYPE input carrying that concrete shape as its type. const payload = ret.properties.payload; - expect(payload.input).toBe("data"); - expect(Object.keys(payload.properties)).toEqual(["name"]); - expect(payload.properties.name.input).toBe("text"); + expect(payload.input).toBe("type"); + expect(payload.type).toBe("{ name: string; }"); // The remaining REST fields are open objects. expect(ret.properties.headers.input).toBe("data"); @@ -1395,7 +1397,7 @@ describe("Schema", () => { // 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"}); + expect(ret.properties.payload).toEqual({input: "type", type: "number"}); expectNoSuggestionsAnywhere(ret); }); @@ -1459,6 +1461,32 @@ describe("Schema", () => { }); }); + describe("TYPE data type", () => { + // TYPE is declared as `type: "any"`, but the schema layer must surface a + // dedicated type input instead of the generic input its underlying type + // would otherwise produce. Like DATE, it is a custom-input data type + // detected by its identifier alone and carries no additional properties. + it("resolves to a type input", () => { + // `type` renders the underlying type; TYPE is branded over `object` + // (see getSharedTypeDeclarations) so its alias survives detection while + // the rendered `type` stays a clean "object". + expect(getTypeSchema("TYPE", DATA_TYPES)).toEqual({ + input: "type", + type: "object", + }); + }); + + it("resolves to a type input when nested in a list and object", () => { + const list = getTypeSchema("LIST", DATA_TYPES) as any; + expect(list.input).toBe("list"); + expect(list.items[0]).toEqual({input: "type", type: "object"}); + + const object = getTypeSchema("{ schema: TYPE }", DATA_TYPES) as any; + expect(object.input).toBe("data"); + expect(object.properties.schema).toEqual({input: "type", type: "object"}); + }); + }); + describe("FILE data type", () => { // FILE is declared as `{ contentType: M; valueType: 'base64'; value: string }`, // and the schema layer expands those internal properties into a `data` From c3dd85f2a1b4467c0ccf267028b09621a915a4bb Mon Sep 17 00:00:00 2001 From: nicosammito Date: Fri, 18 Sep 2026 22:20:44 +0200 Subject: [PATCH 5/7] feat: enhance REST adapter flow signature handling and extract declared return type for better schema resolution --- src/schema/getSignatureSchema.ts | 27 +++++++++++++++++++++++++++ test/flowSchemas.test.ts | 22 +++++++++++++--------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index c8b4f88..eeb3b56 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -136,6 +136,12 @@ export const getSignatureSchema = ( // describes the value the function produces, so it carries no input // suggestions. const returnType = extractReturnType(checker, node, funktion) + // The declared return type is resolved alongside the (possibly generic- + // instantiated) concrete one: only the declaration still carries a type + // parameter's constraint (e.g. a REST trigger's `` payload), + // which the instantiated return has lost. getSchema uses it to recover + // custom inputs while still rendering the concrete instantiated type. + const declaredReturnType = extractDeclaredReturnType(checker, funktion) const returnSchema: Schema = returnType ? getSchema( checker, @@ -144,6 +150,10 @@ export const getSignatureSchema = ( Array.from(declaredFunctionsMap.values()), functions, false, + undefined, + undefined, + undefined, + declaredReturnType, ) : {input: "generic"} @@ -185,6 +195,23 @@ const extractReturnType = ( return undefined } +/** + * Extracts the *declared* return type from the function declaration itself, + * without instantiating its type parameters from a call expression. Unlike + * {@link extractReturnType}, the result keeps any type parameters (and their + * constraints) intact — e.g. `REST_ADAPTER_INPUT` with `T extends TYPE` stays + * generic — so a custom-input constraint the instantiated return has erased can + * still be recovered. Returns undefined when the declaration has no signature. + */ +const extractDeclaredReturnType = ( + checker: ts.TypeChecker, + funktion: ts.FunctionDeclaration | undefined, +): Type | undefined => { + if (!funktion) return undefined + const signature = checker.getSignatureFromDeclaration(funktion) + return signature ? checker.getReturnTypeOfSignature(signature) : undefined +} + /** * Creates a map of all function declarations in the source file. * diff --git a/test/flowSchemas.test.ts b/test/flowSchemas.test.ts index 8c0dc36..7796bb9 100644 --- a/test/flowSchemas.test.ts +++ b/test/flowSchemas.test.ts @@ -100,28 +100,32 @@ describe("getFlowSchemas", () => { expect(result?.outputSchema).toEqual({type: "number"}); }); - it("derives schemas from a generic REST adapter flow signature", () => { - // Taken from the REST trigger scenario in schema.test.ts: the signature - // carries a free type parameter T in TYPE and REST_ADAPTER_INPUT. + it("derives schemas from a REST adapter flow signature", () => { + // Taken from the REST trigger scenario in schema.test.ts: input_schema is + // the non-generic TYPE data type, echoed by the payload of + // REST_ADAPTER_INPUT. const flow = flowWithNodes( [], - "(input_schema: TYPE, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", + "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", ); const result = getFlowSchemas(flow, FUNCTION_SIGNATURES, DATA_TYPES); - // A free T yields the open schema {}; concrete parameters resolve fully. - expect(result?.inputSchema.properties?.input_schema).toEqual({}); + // TYPE is `any` underneath, so it maps to the open object schema; the + // concrete parameters resolve fully. + expect(result?.inputSchema.properties?.input_schema) + .toEqual({}); 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 T-typed payload - // stays open while the concrete fields become object schemas. + // REST_ADAPTER_INPUT resolves structurally; the TYPE payload maps to + // the open object schema while the concrete fields become object schemas. expect(result?.outputSchema.type).toBe("object"); - expect(result?.outputSchema.properties?.payload).toEqual({}); + expect(result?.outputSchema.properties?.payload) + .toEqual({}); expect(result?.outputSchema.required) .toEqual(["payload", "headers", "query_params", "path_params"]); }); From ed968216bb3de3cbc031f57f75ebdc41059da96c Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 21 Sep 2026 14:30:57 +0200 Subject: [PATCH 6/7] feat: add tests for cast handling in generated flow source and enhance schema validation for TYPE inputs --- src/schema/getSignatureSchema.ts | 54 ++++--- src/util/schema.util.ts | 32 +++++ src/utils.ts | 140 ++++++++++++++++-- test/flowCast.test.ts | 215 +++++++++++++++++++++++++++ test/schema/schema.test.ts | 240 ++++++++++++++++++++++++++++++- 5 files changed, 632 insertions(+), 49 deletions(-) create mode 100644 test/flowCast.test.ts diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index eeb3b56..e001b5e 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -1,7 +1,7 @@ import {DataType, Flow, FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types" import {createCompilerHost, generateFlowSourceCode, sanitizeId} from "../utils" import ts, {Type} from "typescript" -import {genericNodeSchema, getSchema, mergeSchemas, normalizeNodeSchema, Schema} from "../util/schema.util" +import {genericNodeSchema, getSchema, isCustomInputKind, mergeSchemas, normalizeNodeSchema, Schema} from "../util/schema.util" /** * Represents the schema information for a node parameter. @@ -135,13 +135,15 @@ export const getSignatureSchema = ( // 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. + // + // The *declared* return type is deliberately not threaded through here: its + // type parameters still carry their constraints (e.g. a REST trigger's + // `` payload), and recovering a custom input from such a + // constraint would brand the return as a type picker. A return describes a + // produced value, never a slot the user fills, so a custom input like TYPE + // can never be the right answer for it — only the concrete instantiated type + // is resolved, which renders the shape the argument actually bound to. const returnType = extractReturnType(checker, node, funktion) - // The declared return type is resolved alongside the (possibly generic- - // instantiated) concrete one: only the declaration still carries a type - // parameter's constraint (e.g. a REST trigger's `` payload), - // which the instantiated return has lost. getSchema uses it to recover - // custom inputs while still rendering the concrete instantiated type. - const declaredReturnType = extractDeclaredReturnType(checker, funktion) const returnSchema: Schema = returnType ? getSchema( checker, @@ -150,10 +152,6 @@ export const getSignatureSchema = ( Array.from(declaredFunctionsMap.values()), functions, false, - undefined, - undefined, - undefined, - declaredReturnType, ) : {input: "generic"} @@ -195,23 +193,6 @@ const extractReturnType = ( return undefined } -/** - * Extracts the *declared* return type from the function declaration itself, - * without instantiating its type parameters from a call expression. Unlike - * {@link extractReturnType}, the result keeps any type parameters (and their - * constraints) intact — e.g. `REST_ADAPTER_INPUT` with `T extends TYPE` stays - * generic — so a custom-input constraint the instantiated return has erased can - * still be recovered. Returns undefined when the declaration has no signature. - */ -const extractDeclaredReturnType = ( - checker: ts.TypeChecker, - funktion: ts.FunctionDeclaration | undefined, -): Type | undefined => { - if (!funktion) return undefined - const signature = checker.getSignatureFromDeclaration(funktion) - return signature ? checker.getReturnTypeOfSignature(signature) : undefined -} - /** * Creates a map of all function declarations in the source file. * @@ -705,6 +686,23 @@ const buildValueDrivenObjectSchema = ( : undefined const isDataKind = funcSchema?.input === "data" + // A custom-input data type on the function side (e.g. a `TYPE` parameter, or + // a `` type picker whose constraint resolves to one) keeps its + // dedicated input even when the entered value is an object literal. Expanding + // it into a structural `data` object would drop the custom input, so surface + // the custom input directly and carry the entered value's concrete shape as + // the rendered `type` — mirroring how the instantiated return payload renders + // (see getSchema's custom-input handling). + if (funcSchema && isCustomInputKind(funcSchema.input as string | undefined)) { + return { + input: funcSchema.input, + type: checker.typeToString( + checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)), + ), + ...(suggestions?.length ? {suggestions} : {}), + } as Schema + } + const properties: Record = {} const required: string[] = [] diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index e86aaa6..b77534c 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -41,6 +41,22 @@ export const isCustomInputIdentifier = ( ): identifier is CustomInputIdentifier => identifier != null && identifier in CUSTOM_INPUT_IDENTIFIERS; +/** The set of input kinds produced by {@link CUSTOM_INPUT_IDENTIFIERS} (e.g. "date", "type"). */ +const CUSTOM_INPUT_KINDS: ReadonlySet = new Set( + Object.values(CUSTOM_INPUT_IDENTIFIERS), +); + +/** + * Returns true if the given input kind is one produced by a custom-input data + * type (e.g. "date", "type"). Used to keep a custom input intact where the + * pipeline would otherwise expand it — e.g. an object literal entered against a + * `TYPE` (or ``) parameter must stay a "type" input rather than + * being turned into a structural "data" object. + */ +export const isCustomInputKind = ( + input: string | undefined, +): boolean => input != null && CUSTOM_INPUT_KINDS.has(input); + /** * Base interface for all input types. * Provides common properties for suggestions and input metadata. @@ -826,6 +842,22 @@ export const mergeSchemas = ( }; } + // A custom-input data type (e.g. TYPE) keeps its dedicated input, but a + // supplied primitive value narrows the rendered `type` from the declared + // bound (TYPE's wide "object") to the value's concrete base type — so `42` + // against a `` (or plain `TYPE`) slot renders + // {input:"type", type:"number"}, mirroring the instantiated return payload. + // Without a value the node side falls back to the function type, so the bound + // is preserved. Concrete-bound custom inputs (DATE = number) are unaffected: + // their node-side type already equals the bound. + if (isCustomInputKind(functionSchema.input as string | undefined)) { + return { + ...functionSchema, + ...(nodeSchema.type !== undefined ? {type: nodeSchema.type} : {}), + ...(suggestions ? {suggestions} : {}), + }; + } + return { ...functionSchema, ...(suggestions ? {suggestions} : {}), diff --git a/src/utils.ts b/src/utils.ts index 2c57bcd..b224b1b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,8 @@ import { SubFlowValue, NodeParameter, ReferenceValue, Maybe, - InlineReferenceValue + InlineReferenceValue, + FlowSetting } from "@code0-tech/sagittarius-graphql-types"; import ts from "typescript"; import {createSystem, createVirtualTypeScriptEnvironment, VirtualTypeScriptEnvironment} from "@typescript/vfs" @@ -124,6 +125,16 @@ function genericParamNames(key: string): string[] { .filter(name => name.length > 0); } +// The only underlying types a custom-input data type preserves via a brand +// intersection (` & {}`): a concrete primitive whose value/inference +// behaviour must stay intact (e.g. DATE = number). Every other underlying — a top +// type (any/unknown/object), an empty or missing `type`, or a generic parameter — +// is branded as the empty object type `{}` instead, because ` & {}` either +// collapses (`any & {}` → `any`, dropping the alias) or is not detected, whereas +// `{}` keeps the alias, renders as the neutral "object" and stays a supertype of +// every value. +const BRAND_PRESERVING_UNDERLYING_TYPES: ReadonlySet = new Set(["number", "string", "boolean"]); + /** * Extracts and returns common type and generic declarations from DATA_TYPES. */ @@ -133,7 +144,16 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s .join("\n"); const typeAliasDeclarations = dataTypes?.map(dt => { - const generics = (dt.genericKeys?.length ?? 0) > 0 ? `<${dt.genericKeys?.join(",")}>` : ""; + const isCustom = isCustomInputIdentifier(dt.identifier); + // A custom-input data type gets an `= any` default per type parameter (unless + // the key already declares one), so a *bare* reference — `TYPE` rather than + // `TYPE<...>` — still resolves to the branded alias below instead of failing + // to bind its parameters. The branding makes the body independent of them. + const generics = (dt.genericKeys?.length ?? 0) > 0 + ? isCustom + ? `<${dt.genericKeys!.map(k => k.includes("=") ? k : `${k} = any`).join(", ")}>` + : `<${dt.genericKeys?.join(",")}>` + : ""; // Custom-input data types (e.g. DATE) map to a dedicated input based on // their identifier alone. TypeScript discards the alias name of bare // primitive aliases (`type DATE = number` resolves to plain `number`), @@ -142,17 +162,18 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s // type — staying mutually assignable with the base type — so the schema // layer can recover the identifier and surface the mapped input. // - // `any` is the exception: `any & {}` collapses straight back to `any`, - // dropping the alias. The `any`-typed TYPE is therefore branded as the - // empty object type `{}`, which keeps its alias name on the resolved type - // (surviving nesting like DATE does) while staying a supertype of every + // Only a concrete primitive underlying is brand-preserved that way (see + // BRAND_PRESERVING_UNDERLYING_TYPES); every other underlying is branded as + // the empty object type `{}`, which keeps the alias name on the resolved + // type (surviving nesting like DATE does) while staying a supertype of every // value — so a `` constraint still binds T to the concrete // argument, primitive or object alike. - const type = !isCustomInputIdentifier(dt.identifier) + const underlying = dt.type?.trim() ?? ""; + const type = !isCustom ? dt.type - : dt.type?.trim() === "any" - ? "{}" - : `${dt.type} & {}`; + : BRAND_PRESERVING_UNDERLYING_TYPES.has(underlying) + ? `${dt.type} & {}` + : "{}"; return `type ${dt.identifier}${generics} = ${type};`; }).join("\n"); @@ -167,6 +188,68 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s return `${useGenericDeclarations ? genericDeclarations : ""}\n${typeAliasDeclarations}\n${widenedDeclarations}`; } +/** + * Characters a cast may consist of. A cast is backend-provided text that is + * spliced verbatim into the generated source, so it is only accepted when it + * looks like a type expression: identifiers, the punctuation type syntax uses + * (generic arguments, tuples/arrays, object members, unions/intersections, + * string literal types) and whitespace. Notably absent are `/` (so no comment + * can be opened), `;`, `=` and backticks — a cast carrying any of those could + * terminate the assertion and turn the rest of the file into arbitrary code. + */ +const CAST_ALLOWED_CHARACTERS = /^[A-Za-z0-9_$<>\[\]{}(),.:?|&+\-'"\s]+$/; + +/** + * Whether a cast can be safely spliced into the generated source. Beyond the + * character allow-list the brackets must be balanced (outside of string literal + * types), because an unbalanced `<` or `{` would swallow the remainder of the + * file and fail the parse of the whole flow rather than just this one value. + */ +const isSafeCast = (cast: string): boolean => { + if (!CAST_ALLOWED_CHARACTERS.test(cast)) return false; + + const closing: Record = {"<": ">", "[": "]", "{": "}", "(": ")"}; + const stack: string[] = []; + let quote: string | null = null; + for (const ch of cast) { + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + } else if (closing[ch]) { + stack.push(closing[ch]); + } else if (ch === ">" || ch === "]" || ch === "}" || ch === ")") { + if (stack.pop() !== ch) return false; + } + } + return quote === null && stack.length === 0; +}; + +/** + * Applies the cast a parameter or flow setting carries to its generated value + * expression, emitting `() as unknown as `. + * + * The cast is the type the value is *meant* to have, which is what the rest of + * the pipeline has to see: it drives the generic inference of the surrounding + * call (a bare `[]` cast to `LIST` binds the function's type parameter), + * and with it the node's return type, the suggestions and the generated JSON + * schemas. The detour through `unknown` makes the assertion unconditional: a + * cast is a deliberate reinterpretation of the value, so it must hold even + * between types TypeScript considers non-overlapping (a JSON literal stated to + * be some data type) instead of failing the flow with a conversion error. + * + * The value is parenthesised so the assertion applies to the whole expression + * and not, e.g., to the body of a generated sub-flow lambda. A cast that is + * empty or not a safe type expression is dropped and the value emitted as-is. + */ +const applyCast = (expression: string, cast?: Maybe): string => { + const type = cast?.trim(); + if (!type || !isSafeCast(type)) return expression; + return `(${expression}) as unknown as ${type}`; +}; + /** * Sanitizes an ID for use as a TypeScript variable name. */ @@ -347,13 +430,24 @@ export function generateFlowSourceCode( const params = (node.parameters?.nodes as NodeParameter[]) || []; const args = params.map((p, index) => { const val = p.value; - if (!val) return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`; + // 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`; + if (!val) return missing; if (val.__typename === "ReferenceValue") { - return `/* @pos ${id} ${index} */ ${renderReference(val as ReferenceValue)}`; + return `/* @pos ${id} ${index} */ ${applyCast(renderReference(val as ReferenceValue), p.cast)}`; } if (val.__typename === "LiteralValue") { const jsonString = renderLiteral(val.value, val.references, {id, index, indent}); - return `/* @pos ${id} ${index} */ ${jsonString}`; + // A null/undefined literal renders as nothing; it is emitted as the + // `undefined` placeholder and, like the missing value above, uncast. + if (jsonString === undefined) return `/* @pos ${id} ${index} */ undefined`; + return `/* @pos ${id} ${index} */ ${applyCast(jsonString, p.cast)}`; } if (val.__typename === "SubFlowValue") { // Direct mapping: the sub-flow *is* an existing function, with no @@ -361,9 +455,15 @@ export function generateFlowSourceCode( // 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. + // + // A cast is deliberately not applied here: the generated lambda is + // contextually typed by the callback parameter of the surrounding + // call, which is what gives its inputs their real (instantiated) + // types — an assertion would replace that context and the sub-flow + // would lose both its input types and its schemas. return `/* @pos ${id} ${index} */ ${renderSubFlow(val as SubFlowValue, id, index, indent)}`; } - return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`; + return missing; }); const varName = `node_${sanitizeId(node.id!)}`; @@ -407,7 +507,17 @@ export function generateFlowSourceCode( if (p?.value?.__typename === "SubFlowValue" && (p.value.startingNodeId || p.value.functionDefinition?.id)) subTreeIds.add(p.value.startingNodeId || p.value.functionDefinition?.id); })); - const flowCode = flow ? `const flow_${sanitizeId(flow.id ?? "")} = /* @pos null null */ flow(${flow.settings?.nodes?.map((setting, index) => `/* @pos null ${index} */ ${setting?.value !== null && setting?.value !== undefined ? stringify(setting?.value) : undefined}`).join(", ") ?? ""});` : "" + // A flow setting is emitted as a positional argument of the flow signature and, + // like a node parameter, carries the cast its value is meant to have (a setting + // without a value stays the uncast `undefined` placeholder). + const renderSetting = (setting: Maybe, index: number): string => { + const value = setting?.value !== null && setting?.value !== undefined + ? stringify(setting.value) + : undefined; + return `/* @pos null ${index} */ ${value === undefined ? "undefined" : applyCast(value, setting?.cast)}`; + }; + + const flowCode = flow ? `const flow_${sanitizeId(flow.id ?? "")} = /* @pos null null */ flow(${flow.settings?.nodes?.map(renderSetting).join(", ") ?? ""});` : "" const executionCode = nodes .filter(n => n?.id && !nextNodeIds.has(n.id) && !subTreeIds.has(n.id)) diff --git a/test/flowCast.test.ts b/test/flowCast.test.ts new file mode 100644 index 0000000..519fa16 --- /dev/null +++ b/test/flowCast.test.ts @@ -0,0 +1,215 @@ +import {describe, expect, it} from "vitest"; +import {Flow, NodeParameter} from "@code0-tech/sagittarius-graphql-types"; +import {generateFlowSourceCode} from "../src/utils"; +import {getFlowValidation} from "../src/validation/getFlowValidation"; +import {getSignatureSchema} from "../src/schema/getSignatureSchema"; +// @ts-ignore +import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data"; + +/** + * A parameter or flow setting may carry a `cast` — the type its value is meant to + * have. The generated source has to apply it, because it is what binds the type + * parameters of the surrounding call and therefore drives validation, the node's + * return type and the generated schemas. + */ +describe("cast handling in the generated flow source", () => { + + /** `std::list::first` is `(list: LIST): T` — its return is the cast's element type. */ + const firstOf = (parameter: NodeParameter, nextNodeId?: string): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::list::first"}, + parameters: {nodes: [parameter]}, + nextNodeId, + }, + ...(nextNodeId ? [{ + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::number::add"}, + parameters: { + nodes: [ + { + value: { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }, + }, + {value: {__typename: "LiteralValue", value: 1}}, + ], + }, + }] : []), + ], + }, + } as Flow); + + it("asserts a literal parameter value to its cast", () => { + const source = generateFlowSourceCode( + firstOf({value: {__typename: "LiteralValue", value: []}, cast: "LIST"}), + FUNCTION_SIGNATURES, + DATA_TYPES, + ); + + expect(source).toContain("([]) as unknown as LIST"); + }); + + it("asserts a reference parameter value to its cast", () => { + const flow = firstOf( + {value: {__typename: "LiteralValue", value: []}}, + "gid://sagittarius/NodeFunction/2", + ); + flow.nodes!.nodes![1]!.parameters!.nodes![0]!.cast = "NUMBER"; + + const source = generateFlowSourceCode(flow, FUNCTION_SIGNATURES, DATA_TYPES); + + expect(source).toContain("as unknown as NUMBER"); + expect(source).toMatch(/\(\(node_gid___sagittarius_NodeFunction_1\)!?\) as unknown as NUMBER/); + }); + + it("binds the call's type parameter from the cast", () => { + // `[]` alone tells `(list: LIST)` nothing, so the node's return type + // would stay unconstrained; cast to LIST it is a TEXT, which the + // NUMBER parameter of the following node rejects. + const cast = getFlowValidation( + firstOf( + {value: {__typename: "LiteralValue", value: []}, cast: "LIST"}, + "gid://sagittarius/NodeFunction/2", + ), + FUNCTION_SIGNATURES, + DATA_TYPES, + ); + + expect(cast.isValid).toBe(false); + expect(cast.diagnostics.some(d => + d.nodeId === "gid://sagittarius/NodeFunction/2" && d.parameterIndex === 0)).toBe(true); + + const uncast = getFlowValidation( + firstOf( + {value: {__typename: "LiteralValue", value: []}}, + "gid://sagittarius/NodeFunction/2", + ), + FUNCTION_SIGNATURES, + DATA_TYPES, + ); + + expect(uncast.isValid).toBe(true); + }); + + it("holds even where the value and the cast do not overlap", () => { + // A cast is a deliberate reinterpretation, so stating that a number list + // is a TEXT list is not a conversion error — it simply takes effect. + const result = getFlowValidation( + firstOf( + {value: {__typename: "LiteralValue", value: [1, 2, 3]}, cast: "LIST"}, + "gid://sagittarius/NodeFunction/2", + ), + FUNCTION_SIGNATURES, + DATA_TYPES, + ); + + // The only complaint is downstream: the TEXT the cast produced does not + // fit the NUMBER parameter of the following node. + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ]); + }); + + it("drops a cast that is not a safe type expression", () => { + const injected = generateFlowSourceCode( + firstOf({ + value: {__typename: "LiteralValue", value: []}, + cast: "TEXT; const injected = 1", + }), + FUNCTION_SIGNATURES, + DATA_TYPES, + ); + expect(injected).not.toContain("injected"); + expect(injected).toContain("*/ []"); + + const unbalanced = generateFlowSourceCode( + firstOf({value: {__typename: "LiteralValue", value: []}, cast: "LIST { + const source = generateFlowSourceCode( + firstOf({value: null, cast: "LIST"}), + 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", () => { + const source = generateFlowSourceCode( + firstOf({value: null, cast: "LIST"}), + FUNCTION_SIGNATURES, + DATA_TYPES, + true, + ); + + expect(source).toContain("({}) as unknown as LIST"); + }); + + it("asserts a flow setting value to its cast", () => { + // The REST trigger is `(input_schema: T, ...)`: whatever + // the `input_schema` setting resolves to is what T — and with it the + // trigger's payload — binds to. + const restTrigger = (cast?: string): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: + "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT", + settings: { + nodes: [ + {value: {name: "text"}, cast}, + {value: "/users"}, + {value: "GET"}, + ], + }, + nodes: {nodes: []}, + } as Flow); + + const cast = "{ name: TEXT, age: NUMBER }"; + expect(generateFlowSourceCode(restTrigger(cast), FUNCTION_SIGNATURES, DATA_TYPES)) + .toContain(`as unknown as ${cast}`); + + // Uncast, T is the shape of the setting value itself. The payload is a + // return, so it renders that shape as data — never as the `T extends + // TYPE` picker the parameter side uses. + const uncastPayload = (getSignatureSchema(restTrigger(), DATA_TYPES, FUNCTION_SIGNATURES) + .return as { properties: Record }).properties.payload; + expect(uncastPayload.input).toBe("data"); + expect(uncastPayload.type).toBe("{ name: string; }"); + + // Cast, T is the cast type — the payload gains the declared `age`. + const castPayload = (getSignatureSchema(restTrigger(cast), DATA_TYPES, FUNCTION_SIGNATURES) + .return as { properties: Record }).properties.payload; + expect(castPayload.input).toBe("data"); + expect(castPayload.properties.age.input).toBe("number"); + expect(castPayload.type).toBe("{ name: string; age: number; }"); + }); +}); diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 2371414..381b212 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1243,6 +1243,24 @@ describe("Schema", () => { ); }; + // Asserts no schema node in the tree is a TYPE custom input. TYPE marks a + // slot the user fills with a type; a return only describes the value the + // signature produces, so it can never be a type picker — not at the root, + // and not in a nested property or list item instantiated from a + // `` bound. + const expectNoTypeInputAnywhere = (schema: any, path = "return"): void => { + expect(schema.input, `${path} is a TYPE custom input`).not.toBe("type"); + for (const [key, child] of Object.entries(schema.properties ?? {})) { + const children = Array.isArray(child) ? child : [child]; + children.forEach((c, i) => + expectNoTypeInputAnywhere(c, `${path}.properties.${key}[${i}]`), + ); + } + (schema.items ?? []).forEach((item: any, i: number) => + expectNoTypeInputAnywhere(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", [ @@ -1371,10 +1389,12 @@ describe("Schema", () => { ); // T is bound from the input_schema setting ({name: TEXT}). The - // parameter is declared as `T extends TYPE`, so the resolved payload - // is a TYPE input carrying that concrete shape as its type. + // parameter is declared as `T extends TYPE`, but a return describes a + // produced value — never a slot the user fills — so the TYPE bound is + // not carried over: the payload is the concrete shape T bound to. const payload = ret.properties.payload; - expect(payload.input).toBe("type"); + expect(payload.input).toBe("data"); + expect(payload.properties.name.input).toBe("text"); expect(payload.type).toBe("{ name: string; }"); // The remaining REST fields are open objects. @@ -1382,8 +1402,10 @@ describe("Schema", () => { 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. + // A return type describes an output → no suggestions and no TYPE + // picker anywhere. expectNoSuggestionsAnywhere(ret); + expectNoTypeInputAnywhere(ret); }); it("instantiates the trigger's generic return payload from a primitive input_schema setting", () => { @@ -1395,10 +1417,13 @@ describe("Schema", () => { expect(result.nodeId).toBeUndefined(); - // input_schema = 42 → T = NUMBER → payload is a number input. + // input_schema = 42 → T = NUMBER → payload is a number input. A + // return describes a produced value, so the `T extends TYPE` bound + // never surfaces here as a type picker — only what T bound to. const ret = result.return as { properties: Record }; - expect(ret.properties.payload).toEqual({input: "type", type: "number"}); + expect(ret.properties.payload).toEqual({input: "number", type: "number"}); expectNoSuggestionsAnywhere(ret); + expectNoTypeInputAnywhere(ret); }); it("never carries suggestions, whatever the return type", () => { @@ -1487,6 +1512,209 @@ describe("Schema", () => { }); }); + describe("TYPE custom input on parameters", () => { + // TYPE is a parameter-only custom input: it marks a slot the user fills + // with a type, so it surfaces on `result.parameters[i].schema` (here the + // `input_schema` argument) and never on a return, which merely describes + // the value the signature produces (see the "return schema" describe). + // + // A trigger is analyzed at the flow level (no nodeId): the flow's own + // signature declares the parameters and its `settings` supply the values + // each parameter is resolved against. + const GENERIC_SIG = + "(input_schema: T, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT"; + const DIRECT_SIG = + "(input_schema: TYPE, httpURL: HTTP_URL, httpMethod: HTTP_METHOD): REST_ADAPTER_INPUT"; + + const flowFor = (signature: string, firstValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature, + settings: { + nodes: [ + {value: firstValue}, + {value: "/u"}, + {value: "GET"}, + ], + }, + nodes: {nodes: []}, + } as Flow); + + // Schema of the first parameter (`input_schema`) at the flow level. + const paramSchema = (firstValue: any, signature = GENERIC_SIG) => + (getSignatureSchema( + flowFor(signature, firstValue), + DATA_TYPES, + FUNCTION_SIGNATURES, + ).parameters as any)[0].schema; + + // Clone DATA_TYPES, replacing the TYPE entry's `type` (and optionally + // other fields) — used to probe how the branding in + // getSharedTypeDeclarations reacts to different underlying declarations. + const dataTypesWithType = (type: any, extra?: Partial): DataType[] => + DATA_TYPES.map(dt => + dt.identifier === "TYPE" ? {...dt, type, ...extra} : dt, + ); + + describe("generic `T extends TYPE`", () => { + // The invariant: a `` parameter is a type picker, so + // its own schema must always be the TYPE custom input ("type"), + // regardless of which value happens to be supplied. + + it("resolves to a type input when no value is supplied", () => { + expect(paramSchema(undefined).input).toBe("type"); + }); + + it("resolves to a type input for a null value", () => { + expect(paramSchema(null).input).toBe("type"); + }); + + it("resolves a primitive number value to a type input carrying its type", () => { + // A supplied primitive narrows the rendered `type` from the T bound + // (TYPE's wide "object") to the value's concrete base type, while + // the input stays the "type" custom input — the parameter is still + // a type picker whatever value it currently holds. + expect(paramSchema(42)).toEqual({input: "type", type: "number"}); + }); + + it("resolves a primitive string value to a type input carrying its type", () => { + expect(paramSchema("hi")).toEqual({input: "type", type: "string"}); + }); + + it("resolves an object value to a type input carrying its shape", () => { + // BUG (fails today): an object value takes the value-driven object + // path in generateNodeSchemas (buildValueDrivenObjectSchema in + // getSignatureSchema.ts), which hardcodes input:"data" and drops + // the function-side TYPE custom input. The soll is a "type" input + // that still carries the concrete shape as its rendered type. + expect(paramSchema({name: "text"})).toEqual({ + input: "type", + type: "{ name: string; }", + }); + }); + + it("resolves a nested object value to a type input", () => { + // BUG (fails today): same value-driven object path → input:"data". + expect(paramSchema({a: {b: 1}}).input).toBe("type"); + }); + }); + + describe("direct `input_schema: TYPE` (non-generic)", () => { + // The same TYPE custom input, reached without a generic slot. This + // shows the inconsistency is not generic-specific: the object-value + // path drops the custom input here too. + + it("resolves to a type input for a null value", () => { + expect(paramSchema(null, DIRECT_SIG).input).toBe("type"); + }); + + it("resolves a primitive value to a type input", () => { + // No generic `T` to infer here, so the node-side type does not + // narrow: the value merely satisfies TYPE's wide bound and the + // rendered type stays "object". (The generic `` + // case above narrows to the concrete primitive.) + expect(paramSchema(42, DIRECT_SIG)).toEqual({input: "type", type: "object"}); + }); + + it("resolves an object value to a type input", () => { + // BUG (fails today): resolves to input:"data" via the value-driven + // object path, exactly as in the generic case. + expect(paramSchema({name: "text"}, DIRECT_SIG).input).toBe("type"); + }); + }); + + describe("DATE sibling stays a date input (regression guard)", () => { + // DATE is the sibling custom input, but with a primitive underlying + // type (number). It survives value resolution because a primitive + // value never takes the value-driven object path: it flows through + // mergeSchemas, whose final branch preserves the function-side kind + // (DATE → date). The upcoming TYPE fix must not disturb this. + const DATE_SIG = "(d: DATE): void"; + + it("keeps a date input for a primitive number value", () => { + expect(paramSchema(123, DATE_SIG)).toEqual({input: "date", type: "number"}); + }); + + it("keeps a date input for a null value", () => { + expect(paramSchema(null, DATE_SIG)).toEqual({input: "date", type: "number"}); + }); + }); + + describe("getTypeSchema branding by the TYPE declaration's `type`", () => { + // TYPE maps to a custom input by its identifier alone, so a *non-generic* + // declaration must brand to a "type" input however loosely the backend + // declares its underlying type. getSharedTypeDeclarations (src/utils.ts) + // brands every non-primitive underlying — "any", "unknown", "object", an + // empty or missing `type` — as the empty object `{}` (which keeps the + // alias, while `any & {}` / `unknown & {}` etc. would not). + + it("brands `type: \"any\"` to a type input", () => { + expect(getTypeSchema("TYPE", dataTypesWithType("any"))!.input).toBe("type"); + }); + + it("brands `type: \"unknown\"` to a type input", () => { + expect(getTypeSchema("TYPE", dataTypesWithType("unknown"))!.input).toBe("type"); + }); + + it("brands `type: \"object\"` to a type input", () => { + expect(getTypeSchema("TYPE", dataTypesWithType("object"))!.input).toBe("type"); + }); + + it("brands an empty `type` to a type input", () => { + expect(getTypeSchema("TYPE", dataTypesWithType(""))!.input).toBe("type"); + }); + + it("brands a missing `type` to a type input", () => { + expect(getTypeSchema("TYPE", dataTypesWithType(undefined))!.input).toBe("type"); + }); + }); + + describe("real backend shape: generic identity `TYPE = T`", () => { + // The real backend declares TYPE as a generic identity type + // (`{type: "T", genericKeys: ["T"]}`), referenced both bare (`TYPE`) and + // parameterised (`TYPE<{ ... }>`). getSharedTypeDeclarations brands the + // body to `{}` and gives each type parameter an `= any` default, so the + // alias is recovered and the custom input surfaces at the signature + // level however the parameter is written. + const REAL_TYPE = { + identifier: "TYPE", + type: "T", + genericKeys: ["T"], + name: [], + aliases: [], + } as unknown as DataType; + + const realParamSchema = (signature: string, value: any) => + (getSignatureSchema( + { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature, + settings: {nodes: [{value}]}, + nodes: {nodes: []}, + } as Flow, + [REAL_TYPE], + [], + ).parameters as any)[0].schema; + + for (const signature of [ + "(input_schema: TYPE): void", + "(input_schema: TYPE<{ name: string }>): void", + "(input_schema: T): void", + ">(input_schema: T): void", + ]) { + it(`resolves \`${signature}\` to a type input for every value`, () => { + expect(realParamSchema(signature, null).input).toBe("type"); + expect(realParamSchema(signature, 42).input).toBe("type"); + expect(realParamSchema(signature, {name: "x"})).toEqual({ + input: "type", + type: "{ name: string; }", + }); + }); + } + }); + }); + describe("FILE data type", () => { // FILE is declared as `{ contentType: M; valueType: 'base64'; value: string }`, // and the schema layer expands those internal properties into a `data` From 69db1f76de8500571fea45a558a99757cdbcfb1b Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 21 Sep 2026 15:05:07 +0200 Subject: [PATCH 7/7] feat: refine handling of custom input types to prevent downgrading in schema generation --- src/schema/getSignatureSchema.ts | 26 ++++++++------ src/util/schema.util.ts | 13 ++++--- test/schema/schema.test.ts | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index e001b5e..b4adc92 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -1,7 +1,7 @@ import {DataType, Flow, FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types" import {createCompilerHost, generateFlowSourceCode, sanitizeId} from "../utils" import ts, {Type} from "typescript" -import {genericNodeSchema, getSchema, isCustomInputKind, mergeSchemas, normalizeNodeSchema, Schema} from "../util/schema.util" +import {genericNodeSchema, getSchema, mergeSchemas, normalizeNodeSchema, Schema} from "../util/schema.util" /** * Represents the schema information for a node parameter. @@ -686,16 +686,22 @@ const buildValueDrivenObjectSchema = ( : undefined const isDataKind = funcSchema?.input === "data" - // A custom-input data type on the function side (e.g. a `TYPE` parameter, or - // a `` type picker whose constraint resolves to one) keeps its - // dedicated input even when the entered value is an object literal. Expanding - // it into a structural `data` object would drop the custom input, so surface - // the custom input directly and carry the entered value's concrete shape as - // the rendered `type` — mirroring how the instantiated return payload renders - // (see getSchema's custom-input handling). - if (funcSchema && isCustomInputKind(funcSchema.input as string | undefined)) { + // Value-driven expansion only applies to a *structural* slot: a declared + // `data` object, whose properties and nested cardinality mirror the entered + // value, or a generic slot, which constrains nothing and lets the value drive + // the shape. Any other declared input is dedicated — the function side has + // already decided what the parameter is, and an entered value never downgrades + // it (the same rule mergeSchemas and buildValueDrivenItem follow). + // + // This matters for every data type that is structurally an object but renders + // as its own input (COLOR, FILE, and a `TYPE` / `` picker): + // expanding it here would turn it back into the very `data` shape its input + // replaces. The declared schema is kept as-is — only the rendered `type` takes + // the entered value's concrete shape, mirroring how the instantiated return + // payload renders (see getSchema's custom-input handling). + if (funcSchema && !isDataKind && funcSchema.input !== "generic") { return { - input: funcSchema.input, + ...funcSchema, type: checker.typeToString( checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)), ), diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index b77534c..6d64ce5 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -48,10 +48,15 @@ const CUSTOM_INPUT_KINDS: ReadonlySet = new Set( /** * Returns true if the given input kind is one produced by a custom-input data - * type (e.g. "date", "type"). Used to keep a custom input intact where the - * pipeline would otherwise expand it — e.g. an object literal entered against a - * `TYPE` (or ``) parameter must stay a "type" input rather than - * being turned into a structural "data" object. + * type (e.g. "date", "type"). Used by {@link mergeSchemas} to let a supplied + * value narrow the rendered `type` of such an input while the input kind itself + * is kept. + * + * Note this covers the registry-driven kinds only, so it is not the test for + * "is this input dedicated rather than structural" — COLOR is detected by shape + * (see isColorType) and never appears here. Code that must not expand a + * dedicated input back into the shape it was built from checks for the + * structural kinds it does expand ("data", "generic") instead. */ export const isCustomInputKind = ( input: string | undefined, diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 381b212..9b2a090 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1870,6 +1870,65 @@ describe("Schema", () => { expect(object.input).toBe("data"); expect(object.properties.hue).toEqual({input: "number", type: "number"}); }); + + // The same type reached through getSignatureSchema instead of getTypeSchema. + // A COLOR value is structurally an object, so the value-driven object path + // could expand it back into a `data` input and lose the custom input — the + // invariant is that a supplied value never downgrades a color parameter. + const colorFlow = (signature: string, values: any[]): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature, + settings: {nodes: values.map((value) => ({value}))}, + nodes: {nodes: []}, + } as Flow); + + const colorParams = (signature: string, values: any[]) => + (getSignatureSchema( + colorFlow(signature, values), + DATA_TYPES, + FUNCTION_SIGNATURES, + ).parameters as any[]).map((p) => p.schema); + + it("resolves a COLOR parameter to a color input with and without a value", () => { + const [empty] = colorParams("(background: COLOR): void", [undefined]); + expect(empty.input).toBe("color"); + expect(empty.type).toBe(COLOR_TYPE); + + // A fully specified value (alpha included) must not expand the + // channels into a `data` input. + const [full] = colorParams("(background: COLOR): void", [ + {hue: 210, saturation: 50, lightness: 40, alpha: 0.5}, + ]); + expect(full.input).toBe("color"); + expect(full.properties).toBeUndefined(); + + // alpha is optional, so a value omitting it is still a COLOR. + const [noAlpha] = colorParams("(background: COLOR): void", [ + {hue: 0, saturation: 100, lightness: 50}, + ]); + expect(noAlpha.input).toBe("color"); + }); + + it("keeps color inputs for a LIST parameter carrying values", () => { + const [list] = colorParams("(palette: LIST): void", [ + [ + {hue: 0, saturation: 100, lightness: 50}, + {hue: 120, saturation: 100, lightness: 50, alpha: 1}, + ], + ]); + expect(list.input).toBe("list"); + expect(list.items.map((i: any) => i.input)).toEqual(["color", "color"]); + }); + + it("keeps a color input for a COLOR property of an object parameter", () => { + const [theme] = colorParams("(theme: { background: COLOR, name: TEXT }): void", [ + {background: {hue: 210, saturation: 50, lightness: 40}, name: "dark"}, + ]); + expect(theme.input).toBe("data"); + expect(theme.properties.background.input).toBe("color"); + expect(theme.properties.name.input).toBe("text"); + }); }); describe("list-select input", () => {