diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index b4adc92..88fa711 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -1,7 +1,20 @@ 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 { + declaredItemsOf, + declaredSchema, + DataInput, + genericNodeSchema, + getSchema, + isOptionsUnion, + ListInput, + mergeSchemas, + nonNullishType, + normalizeNodeSchema, + Schema, + withSuggestions, +} from "../util/schema.util" /** * Represents the schema information for a node parameter. @@ -11,9 +24,15 @@ export interface NodeSchema { /** * The schema definition for this node parameter. Produced by merging the * function-declared parameter schema with the node's concrete value schema: - * the function schema drives the structural shape, the node schema contributes - * additional suggestions, and a generic function parameter falls back to the - * node's concrete shape (never as a select). + * the function schema drives the structural shape and the suggestions of every + * nested position, the node schema contributes the parameter's own suggestion + * scope plus the shape and `type` the entered value implies, and a generic + * function parameter falls back to the node's concrete shape (never as a + * select). + * + * Which suggestions a position offers therefore depends on the declared type + * alone — entering a value changes how many items/properties are rendered and + * each position's `type`, never the set of candidates it offers. */ schema: Schema /** Array of parameter indices that must be resolved before this parameter */ @@ -442,6 +461,11 @@ const generateNodeSchemas = ( // entered property mirrors a field, and any list nested inside it renders // one item per entered element (see buildValueDrivenObjectSchema). const functionDeclarations = Array.from(declaredFunctionsMap.values()) + // Built *with* suggestions: the declared type is what decides which + // suggestions a nested property or list element offers, and the merge takes + // them from here so they stay the same whether or not a value was entered + // (the node side's nested suggestions are narrowed by the concrete value — + // see mergeSchemas). const functionSchema = functionParameterType ? getSchema( checker, @@ -449,7 +473,7 @@ const generateNodeSchemas = ( functionParameterType, functionDeclarations, functions, - false + true ) : undefined @@ -460,16 +484,32 @@ const generateNodeSchemas = ( // merge path below and is never forced into a `data` shape. Arrays are // routed by the literal alone: a list slot's cardinality always comes from // the value. - const nodeTypeIsObject = - (parameterType.flags & ts.TypeFlags.Object) !== 0 && - !checker.isArrayType(parameterType) && - !checker.isTupleType(parameterType) + // + // An optional slot resolves to ` | undefined`, and a union carries + // none of its members' type flags — so the nullish part is stripped before + // the test. Without that, an object value in an optional object slot would + // fall through to the merge path and lose every entered field. + const nodeTypeIsObject = isPlainObjectType(checker, nonNullishType(parameterType)) const argExpr = getArgumentExpression(node, index) + + // A union declared parameter type enumerates the *options* of the slot, + // and the entered value picks one of them (see declaredUnionMember). The + // node side resolves such a slot to the union itself, which is neither an + // object nor a list — so without recovering the member here an object + // value in a `COLOR | OBJECT<…>` slot would take the merge path and lose + // every entered field, and the dedicated input of the member it picked. + const declaredMember = argExpr + ? declaredUnionMember(checker, functionParameterType, argExpr) + : undefined + const declaredParameterType = declaredMember ?? functionParameterType + if ( argExpr && (ts.isArrayLiteralExpression(argExpr) || - (ts.isObjectLiteralExpression(argExpr) && nodeTypeIsObject)) + (ts.isObjectLiteralExpression(argExpr) && + (nodeTypeIsObject || + (declaredMember != null && isPlainObjectType(checker, declaredMember))))) ) { const wholeSuggestions = getSchema( checker, @@ -485,7 +525,7 @@ const generateNodeSchemas = ( ? buildValueDrivenListSchema( checker, node, - functionParameterType, + declaredParameterType, argExpr, functionDeclarations, functions, @@ -495,7 +535,7 @@ const generateNodeSchemas = ( : buildValueDrivenObjectSchema( checker, node, - functionParameterType, + declaredParameterType, argExpr, functionDeclarations, functions, @@ -552,6 +592,121 @@ const getArgumentExpression = ( // element type's suggestions (options, references, nodes) are carried along. const PRIMITIVE_ITEM_INPUTS = new Set(["select", "boolean", "number", "text"]) +// Type flags of a value that says nothing about the shape of the slot it sits +// in: an unfilled field, written out as `null` (or left `undefined`), rather than +// a value of a concrete type. +const NO_TYPE_FLAGS = + ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Never + +/** + * Returns true if the value's type carries no information about the slot — a + * `null` entry in an otherwise filled object. The declared type stands for such a + * position: `{test: null}` against `OBJECT<{test: NUMBER}>` is still a number + * input *of type number*, waiting to be filled, not a number input of type + * `null`. + */ +const carriesNoType = (type: Type): boolean => (type.flags & NO_TYPE_FLAGS) !== 0 + +/** + * Returns true if the type is a plain object — the structural shape a `data` + * input is built from — and not a list, which has its own value-driven expansion. + */ +const isPlainObjectType = (checker: ts.TypeChecker, type: Type): boolean => + (type.flags & ts.TypeFlags.Object) !== 0 && + !checker.isArrayType(type) && + !checker.isTupleType(type) + +/** + * The keys an object literal assigns, in source order. Shorthand and spread + * members are skipped — the value-driven expansion only reads plain property + * assignments. + */ +const objectLiteralKeys = (objectExpr: ts.ObjectLiteralExpression): string[] => + objectExpr.properties + .filter(ts.isPropertyAssignment) + .map((property) => propertyKey(property)) + +/** + * The member of a declared *union* type that the entered value picked, or + * `undefined` when there is no union to choose from — or when the value does not + * identify one of its members. + * + * A union declared type enumerates the *options* of a slot: that is what + * `declaredItems` spells out for a list, one entry per member. A value sitting in + * the slot is one of those options, so everything the member declares — its input + * kind, its properties and required list, the suggestions of every level — is + * what describes the entered value. Resolving it is what keeps an entered + * `items[i]` a refinement of one of the `declaredItems` instead of a shape read + * off the raw value: a COLOR in a `LIST>` stays a color input + * rather than being expanded back into the `{hue, saturation, lightness}` object + * the color input replaces. + * + * An options union is deliberately left unresolved: its members are the options + * of *one* input (`'GET' | 'POST' | …` renders a single select offering all six, + * `boolean` one boolean input), not alternative inputs, so narrowing it to the + * entered literal would hide the rest — see {@link isOptionsUnion}. + * + * Matching is tiered, because an entered value is routinely half-filled and a + * half-filled value must still find its member: + * 1. the member the value's type is assignable to; + * 2. for an object literal, the member declaring the most of the entered keys — + * `{test: null}` names `OBJECT<{test: NUMBER}>` even though `null` is not + * assignable to `NUMBER` under strict null checks; + * 3. for an array literal, the member that is itself a list. + * + * A value of type `any`/`unknown` (e.g. an unresolvable reference) matches every + * member and therefore identifies none, so it resolves to nothing. + */ +const declaredUnionMember = ( + checker: ts.TypeChecker, + declaredType: Type | undefined, + value: ts.Expression, +): Type | undefined => { + if (!declaredType) return undefined + + // An optional slot (` | undefined`) is not a union of options; it + // collapses to its single real member, which getSchema resolves on its own. + const stripped = nonNullishType(declaredType) + if (!stripped.isUnion() || isOptionsUnion(stripped)) return undefined + + const members = stripped.types.filter( + (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0, + ) + if (members.length < 2) return undefined + + const valueType = checker.getTypeAtLocation(value) + if ((valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) { + const assignable = members.find((member) => + checker.isTypeAssignableTo(valueType, member), + ) + if (assignable) return assignable + } + + if (ts.isObjectLiteralExpression(value)) { + const keys = objectLiteralKeys(value) + let best: Type | undefined + let bestScore = 0 + for (const member of members) { + const score = keys.filter( + (key) => checker.getPropertyOfType(member, key) != null, + ).length + if (score > bestScore) { + best = member + bestScore = score + } + } + return best + } + + if (ts.isArrayLiteralExpression(value)) { + return members.find( + (member) => checker.isArrayType(member) || checker.isTupleType(member), + ) + } + + return undefined +} + /** * Builds a value-driven list schema from an array-literal argument. * @@ -572,30 +727,63 @@ const buildValueDrivenListSchema = ( anySuggestions?: Schema["suggestions"], ): Schema => { const funcSchema = funcListType - ? getSchema(checker, node, funcListType, functionDeclarations, functions, false) + ? getSchema(checker, node, funcListType, functionDeclarations, functions, true) : undefined const isListKind = funcSchema != null && (funcSchema.input as string | undefined)?.startsWith("list") === true + // The declared element type, looked up on the non-nullish part so an optional + // list (`LIST | undefined`) still yields its element rather than treating + // every entry as an unconstrained slot. + const declaredListType = funcListType ? nonNullishType(funcListType) : undefined const funcElementType = - funcListType && checker.isArrayType(funcListType) - ? checker.getTypeArguments(funcListType as ts.TypeReference)[0] + declaredListType && checker.isArrayType(declaredListType) + ? checker.getTypeArguments(declaredListType as ts.TypeReference)[0] : undefined const items = arrayExpr.elements.map((element) => buildValueDrivenItem(checker, node, funcElementType, element, functionDeclarations, functions, anySuggestions), ) - return { - input: isListKind ? funcSchema!.input : "list", - type: - (isListKind ? funcSchema!.type : undefined) ?? - checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(arrayExpr))), - items, - ...(suggestions?.length ? {suggestions} : {}), - } as Schema + return withSuggestions( + { + input: isListKind ? funcSchema!.input : "list", + type: + (isListKind ? funcSchema!.type : undefined) ?? + checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(arrayExpr))), + items, + // What an element *may* be, kept alongside the entered elements: `items` + // is value-driven here, so on its own it cannot say what to render for a + // new element — and for an empty `[]` it says nothing at all. + declaredItems: declaredItemsOf( + isListKind ? (funcSchema as ListInput).items : undefined, + anySuggestions, + ), + } as Schema, + suggestions ?? ownSuggestions(funcSchema, anySuggestions), + ) } +/** + * The suggestions a value-driven container carries for its own level when the + * caller has none to hand down — i.e. at every nested position, since only the + * parameter root is given the whole-slot set. + * + * It is the declared type's own set, or the constant `any` set when the declared + * type does not constrain this position (no declared type at all, or a generic + * one). That mirrors the rule {@link mergeSchemas} follows, so a nested list or + * object offers the same suggestions whether or not a value was entered — before + * this, a nested container came out with none at all while its own items and + * properties had theirs. + */ +const ownSuggestions = ( + funcSchema: Schema | undefined, + anySuggestions?: Schema["suggestions"], +): Schema["suggestions"] | undefined => + !funcSchema || funcSchema.input === "generic" + ? anySuggestions + : funcSchema.suggestions + /** * Builds a single list item schema for one array-literal element. * @@ -617,19 +805,28 @@ const buildValueDrivenItem = ( functions: FunctionDefinition[], anySuggestions?: Schema["suggestions"], ): Schema => { + // A union declared element (or property) type lists the options of this + // position; the entered value picks one, and that member is what describes + // it. Without this the union — which carries none of its members' type flags + // — reads as an unconstrained slot, and the value would be expanded as if the + // declared type said nothing: a COLOR back into a plain object, a declared + // NUMBER property into whatever the entered value happens to be. + const declaredType = + declaredUnionMember(checker, funcElementType, element) ?? funcElementType + if (ts.isArrayLiteralExpression(element)) { - return buildValueDrivenListSchema(checker, node, funcElementType, element, functionDeclarations, functions, undefined, anySuggestions) + return buildValueDrivenListSchema(checker, node, declaredType, element, functionDeclarations, functions, undefined, anySuggestions) } // A nested object literal recurses into a value-driven object, so a list // buried inside it (e.g. `{test: [1, 1, 1]}`) still renders one item per // entered element instead of collapsing to a single element-type item. if (ts.isObjectLiteralExpression(element)) { - return buildValueDrivenObjectSchema(checker, node, funcElementType, element, functionDeclarations, functions, undefined, anySuggestions) + return buildValueDrivenObjectSchema(checker, node, declaredType, element, functionDeclarations, functions, undefined, anySuggestions) } - const funcElementSchema = funcElementType - ? getSchema(checker, node, funcElementType, functionDeclarations, functions, true) + const funcElementSchema = declaredType + ? getSchema(checker, node, declaredType, functionDeclarations, functions, true) : undefined const funcIsGeneric = !funcElementSchema || funcElementSchema.input === "generic" const valueType = checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(element)) @@ -645,25 +842,33 @@ const buildValueDrivenItem = ( } // Primitive/select element: keep the declared kind and suggestions, but take - // the concrete value's base type as the item type. + // the concrete value's base type as the item type. An unfilled entry (`null`) + // narrows nothing, so there the declared type stands (see carriesNoType). if (PRIMITIVE_ITEM_INPUTS.has(funcElementSchema!.input as string)) { - return { - ...funcElementSchema!, - type: checker.typeToString(valueType), - } as Schema + return withSuggestions( + { + ...funcElementSchema!, + ...(carriesNoType(valueType) + ? {} + : {type: checker.typeToString(valueType)}), + } as Schema, + funcElementSchema!.suggestions, + ) } - // Structured element (object, …): keep the declared schema as-is. - return funcElementSchema! + // Structured element (object, …): keep the declared schema, whose every level + // already carries the suggestions of the type it describes. + return declaredSchema(funcElementSchema!, anySuggestions) } /** * Builds a value-driven object (`data`) schema from an object-literal argument. * - * `properties` has exactly one entry per entered field (like a value-driven - * list's `items` mirror its elements), so cardinality is preserved through every - * nesting level — a list nested inside the object renders one item per element - * instead of collapsing to its single element type. Each property's schema is + * `properties` holds the declared fields plus one entry per entered field, so + * cardinality is preserved through every nesting level — a list nested inside the + * object renders one item per element instead of collapsing to its single element + * type — while a field the value does not mention keeps its declared schema and + * stays renderable. Each property's schema is * built the same way a list element is (see {@link buildValueDrivenItem}): its * input kind and suggestions come from the declared property type when the * function declares a concrete object, and a generic slot lets the value drive @@ -682,7 +887,7 @@ const buildValueDrivenObjectSchema = ( anySuggestions?: Schema["suggestions"], ): Schema => { const funcSchema = funcObjectType - ? getSchema(checker, node, funcObjectType, functionDeclarations, functions, false) + ? getSchema(checker, node, funcObjectType, functionDeclarations, functions, true) : undefined const isDataKind = funcSchema?.input === "data" @@ -700,28 +905,39 @@ const buildValueDrivenObjectSchema = ( // 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 { - ...funcSchema, - type: checker.typeToString( - checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)), - ), - ...(suggestions?.length ? {suggestions} : {}), - } as Schema + return withSuggestions( + { + ...funcSchema, + type: checker.typeToString( + checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)), + ), + } as Schema, + suggestions ?? funcSchema.suggestions, + ) } + // Start from the declared fields: the entered value refines and extends the + // declared object, it never removes from it. A partially filled value would + // otherwise drop every field it does not mention — leaving the UI with no way + // to render (or suggest anything for) the fields still to be filled. A generic + // slot declares nothing, so there the entered fields are all there is. + const declaredProperties = isDataKind ? ((funcSchema as DataInput).properties ?? {}) : {} const properties: Record = {} - const required: string[] = [] + for (const [key, value] of Object.entries(declaredProperties)) { + properties[key] = Array.isArray(value) + ? value.map((member) => declaredSchema(member, anySuggestions)) + : declaredSchema(value, anySuggestions) + } + + const enteredKeys: string[] = [] for (const property of objectExpr.properties) { if (!ts.isPropertyAssignment(property)) continue - const key = - ts.isStringLiteralLike(property.name) || ts.isNumericLiteral(property.name) - ? property.name.text - : property.name.getText() + const key = propertyKey(property) // Only a concrete declared object contributes a per-property type; a // generic slot leaves each entered field unconstrained. const funcPropertyType = isDataKind - ? getObjectPropertyType(checker, funcObjectType!, key) + ? getObjectPropertyType(checker, nonNullishType(funcObjectType!), key) : undefined properties[key] = buildValueDrivenItem( checker, @@ -732,20 +948,37 @@ const buildValueDrivenObjectSchema = ( functions, anySuggestions, ) - required.push(key) + enteredKeys.push(key) } - return { - input: "data", - type: - (isDataKind ? funcSchema!.type : undefined) ?? - checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr))), - properties, - required, - ...(suggestions?.length ? {suggestions} : {}), - } as Schema + // Optionality is a property of the declared type, so its required list stands + // as-is — entering a value neither makes a field required nor relieves it. Only + // a generic slot has no declared list, and there every entered field is taken as + // required: the value is all the shape there is. + const required = isDataKind ? ((funcSchema as DataInput).required ?? []) : enteredKeys + + return withSuggestions( + { + input: "data", + type: + (isDataKind ? funcSchema!.type : undefined) ?? + checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr))), + properties, + required, + } as Schema, + suggestions ?? ownSuggestions(funcSchema, anySuggestions), + ) } +/** + * The name an object-literal property assigns, with a quoted or numeric key read + * as its text rather than its source spelling. + */ +const propertyKey = (property: ts.PropertyAssignment): string => + ts.isStringLiteralLike(property.name) || ts.isNumericLiteral(property.name) + ? property.name.text + : property.name.getText() + /** * Resolves the declared type of a named property on an object type, or undefined * when the type has no such property (e.g. a field entered in the value that the diff --git a/src/util/nodes.util.ts b/src/util/nodes.util.ts index fb663f0..13fe23e 100644 --- a/src/util/nodes.util.ts +++ b/src/util/nodes.util.ts @@ -1,6 +1,5 @@ import ts from "typescript"; import {FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types"; -import {isSubFlow} from "./schema.util"; /** * Filters and transforms function declarations into a collection of compatible node functions. @@ -10,6 +9,13 @@ import {isSubFlow} from "./schema.util"; * are assignable to the specified parameter type. Each node function is enriched with * metadata including function definitions and parameter information. * + * A callable (sub-flow) parameter is no exception: it is matched by the same + * rule as every other slot — a function qualifies when the value it produces is + * assignable to the slot's type. Such a node is offered alongside the + * {@link getSubFlows} candidates, which bind an existing function as the + * sub-flow itself; the two kinds are told apart by their `__typename` and may + * both be present for the same function definition. + * * @param {ts.TypeChecker} checker - The TypeScript type checker instance used to analyze * type information and verify type compatibility * @param {ts.FunctionDeclaration[]} functionDeclarations - Array of TypeScript function @@ -20,8 +26,8 @@ import {isSubFlow} from "./schema.util"; * functions. Only functions with return types assignable to this type are included * * @returns {NodeFunction[]} Array of node functions that are compatible with the - * specified parameter type. Returns an empty array if the parameter type - * is a sub-flow or if no compatible functions are found + * specified parameter type, or an empty array if no compatible + * functions are found * * @example * const compatibleNodes = getNodes(checker, funcDecls, funcDefs, stringType); @@ -32,11 +38,6 @@ export const getNodes = ( functions: FunctionDefinition[], paramType: ts.Type ): NodeFunction[] => { - // Early exit: if the parameter type is a sub-flow, no node functions are applicable - if (isSubFlow(paramType)) { - return []; - } - // Transform each function declaration into a node function if it matches the parameter type return functionDeclarations.flatMap((func) => { const nodeFunction = createNodeFunctionIfCompatible(checker, func, functions, paramType); diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 6d64ce5..0a56499 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -229,8 +229,42 @@ export interface DataInput extends Input { */ export interface ListInput extends Input { input?: "list"; - /** Schema or array of schemas for list items */ + /** + * Schema or array of schemas for list items. + * + * In a parameter schema this is *value-driven*: once an array value is + * entered it holds exactly one entry per entered element, so it says what the + * list currently contains — not what an element may be. Use + * {@link ListInput.declaredItems} for the latter. + * + * An entered entry only ever *refines* one of the `declaredItems`: it is that + * option's input kind, properties and suggestions, with the concrete value's + * `type` and per-element cardinality filled in. A union element type is + * resolved to the member the entered element picked, so the COLOR entries of a + * `LIST>` stay color inputs and its object entries keep the + * declared object's property schemas. + */ items?: Schema[]; + /** + * The item schemas the *declared* element type produces — what `items` holds + * while no value is entered, and therefore the answer to "what may an element + * of this list be": its input kind, and the suggestions an element slot offers. + * A union element contributes one entry per member (e.g. all six methods of a + * `LIST`), so the entries enumerate the element's options rather + * than the list's contents. + * + * Always present alongside `items` on an input slot's schema — an entered + * value can empty `items` (`[]`) or reduce it to the elements that happen to be + * there, and neither tells the UI what to render for a new element or which + * candidates the element slot accepts. An unconstrained element slot (a generic + * `LIST`, or a list in a slot the declared type does not describe) yields a + * single generic entry carrying the "accepts anything" suggestion set. + * + * Omitted on schemas that describe a *produced* value rather than an input slot + * (a signature's return type, {@link getTypeSchema}); there `items` is already + * the declared expansion. + */ + declaredItems?: Schema[]; } /** @@ -350,6 +384,18 @@ export const getSchema = ( // every reference in scope, because the function takes anything. const typeForSuggestions = suggestionType ?? parameterType; + // Sub-flow bindings are the one exception to that scope: they are matched + // against the slot's *callable* type, and a wider scope is not necessarily + // callable where this position is. A union element scopes every one of its + // options to the whole union (see the item expansion below), and a union + // loses its call signatures as soon as one member is not callable — matching + // the bindings against it would drop every one of them from an option that + // is itself a sub-flow. They are therefore scoped to this schema node's own + // (member) type whenever the wider scope has no call signatures to match. + // The other three sources filter by assignability, where the wider scope is + // a superset and needs no such fallback. + const typeForSubFlows = isSubFlow(typeForSuggestions) ? typeForSuggestions : parameterType; + // Collect all available suggestions for this parameter const combinedSuggestions = suggestions ? { suggestions: [ @@ -370,7 +416,7 @@ export const getSchema = ( checker, functionDeclarations, functions, - typeForSuggestions + typeForSubFlows ), ], } : {}; @@ -467,7 +513,8 @@ export const getSchema = ( // A list of FILEs (LIST, FILE[], ...) surfaces a dedicated // multi-file input instead of a generic list of file objects, carrying - // the same mimetype as its element FILE would. + // the same mimetype as its element FILE would. It carries no `items`, and + // hence no `declaredItems` either. if (itemTypes.length === 1 && isFileType(checker, itemTypes[0])) { const mimetype = getFileMimetype(checker, itemTypes[0]); return {input: "list-file", type, mimetype, ...combinedSuggestions}; @@ -476,13 +523,27 @@ export const getSchema = ( // Per-item schemas, computed the same way for a generic list and a // list-select (whose `items` mirror a normal list's). A union element is // split into one schema per member; a single element yields one schema. + // + // A split member describes one *option* of the element slot, not a slot of + // its own, so its suggestions stay scoped to the whole element type: every + // item of a LIST offers all six methods, not just the one its + // `type` names. That is also what the value-driven path produces once + // elements are entered (see buildValueDrivenItem), so an item's suggestions + // no longer depend on whether the list carries a value. const itemSchemas = itemTypes.flatMap(itemType => { const memberTypes = itemType.isUnion() ? itemType.types : [itemType]; + const elementSuggestionType = memberTypes.length > 1 ? itemType : undefined; return memberTypes.map((memberType) => - getSchema(checker, node, memberType, functionDeclarations, functions, suggestions, undefined, visited, recursionCache) + getSchema(checker, node, memberType, functionDeclarations, functions, suggestions, elementSuggestionType, visited, recursionCache) ) }) + // The declared element expansion, kept next to `items` so it survives a + // value overwriting them (see ListInput.declaredItems). Only an input slot + // carries it: a suggestion-less schema describes a produced value, where + // `items` is the declared expansion already. + const declaredItems = suggestions ? {declaredItems: itemSchemas} : {}; + // A list of a select type (LIST, ('GET' | 'POST')[], ...) // surfaces a dedicated multi-select. Its `items` are the element schemas, // exactly like a generic list — only the input kind differs so the UI can @@ -490,7 +551,7 @@ export const getSchema = ( // cases below because a single literal (e.g. LIST<1>) is a select, not a // plain number. if (itemTypes.length === 1 && isSelectType(itemTypes[0])) { - return {input: "list-select", type, items: itemSchemas, ...combinedSuggestions}; + return {input: "list-select", type, items: itemSchemas, ...declaredItems, ...combinedSuggestions}; } // A homogeneous list of a plain primitive surfaces a dedicated @@ -504,19 +565,20 @@ export const getSchema = ( // per-item schema of the generic list below. if (itemTypes.length === 1) { const element = itemTypes[0]; - if (isBoolean(element)) return {input: "list-boolean", type, items: itemSchemas, ...combinedSuggestions}; - if (isNumber(element)) return {input: "list-number", type, items: itemSchemas, ...combinedSuggestions}; - if (isString(element)) return {input: "list-text", type, items: itemSchemas, ...combinedSuggestions}; + if (isBoolean(element)) return {input: "list-boolean", type, items: itemSchemas, ...declaredItems, ...combinedSuggestions}; + if (isNumber(element)) return {input: "list-number", type, items: itemSchemas, ...declaredItems, ...combinedSuggestions}; + if (isString(element)) return {input: "list-text", type, items: itemSchemas, ...declaredItems, ...combinedSuggestions}; // A homogeneous list of a callable/sub-flow element surfaces a // dedicated multi-sub-flow input. Checked after the primitives (none // of which are callable) and before the generic list fallback. - if (isSubFlow(element)) return {input: "list-sub-flow", type, items: itemSchemas, ...combinedSuggestions}; + if (isSubFlow(element)) return {input: "list-sub-flow", type, items: itemSchemas, ...declaredItems, ...combinedSuggestions}; } return { input: "list", type, items: itemSchemas, + ...declaredItems, ...combinedSuggestions, }; } @@ -562,12 +624,22 @@ export const getSchema = ( (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) !== 0 )); - // Filter out undefined and null types from union types - const propertyTypes = propertyType.isUnion() - ? propertyType.types.filter( - (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0 - ) - : [propertyType]; + // A nullish member never describes an input of its own, so it is stripped + // first. What remains may still be a union: a heterogeneous one (e.g. + // `TEXT | OBJECT<…>`) yields one schema per member, while a union that + // renders as a *single* input — a literal-union select, or boolean's + // `true | false` — is kept whole. Splitting those would turn one select + // into one input per option, each carrying only its own literal as a + // suggestion, and the property would stop matching both the single input + // the same type produces as a parameter and the one the value-driven path + // builds once a value is entered. + const nonNullishPropertyType = checker.getNonNullableType(propertyType); + const propertyTypes = + nonNullishPropertyType.isUnion() && + !isSelectType(nonNullishPropertyType) && + !isBoolean(nonNullishPropertyType) + ? nonNullishPropertyType.types + : [nonNullishPropertyType]; // The matching property on the declared type (when tracked), so a // property whose declared type is a custom-input-constrained type @@ -615,20 +687,24 @@ export const getSchema = ( /** * Merges a function-declared parameter schema with the schema derived from the * concrete node value. The function schema is treated as the source of truth for - * the structural shape (input kind, properties, items); the node schema only - * contributes additional suggestions and, when the function schema is generic, - * a fallback shape. + * the structural shape (input kind, properties, items) *and* for the suggestions + * of every nested position; the node schema contributes the parameter root's + * suggestion scope, the value-driven shape of positions the declared type leaves + * open, and — when the function schema is generic — a fallback shape. * * Rules: * - If the function schema is generic, follow the node schema — but never as a * select. A single literal value (e.g. "Test") narrowing a generic T must not * collapse the input into a select with one option; it should remain free-form * text/number/boolean matching the literal kind. - * - Otherwise use the function schema's input kind and merge suggestions from both. - * Recurse into `properties` (for data) and `items` (for list) so nested generics - * inside concrete containers are handled the same way. + * - Otherwise use the function schema's input kind. Recurse into `properties` (for + * data) and `items` (for list) so nested generics inside concrete containers are + * handled the same way. + * - Suggestions come from the declared side at every nested position (see the + * comment on `suggestions` below) and are de-duplicated by structural equality. * - * Suggestions are concatenated and de-duplicated by structural equality. + * Both schemas are expected to carry suggestions (built with the flag enabled); + * the node side is additionally passed through {@link normalizeNodeSchema} first. * * @param functionSchema - The schema derived from the declared function parameter type * @param nodeSchema - The schema derived from the node's concrete (narrowed) parameter type @@ -685,6 +761,11 @@ export const normalizeNodeSchema = (schema: Schema): Schema => { result = {...result, items: items.map(normalizeNodeSchema)} as Schema; } + const declared = (schema as ListInput).declaredItems; + if (declared) { + result = {...result, declaredItems: declared.map(normalizeNodeSchema)} as Schema; + } + const properties = (schema as DataInput).properties; if (properties) { const mapped: Record = {}; @@ -709,6 +790,89 @@ export const normalizeNodeSchema = (schema: Schema): Schema => { return result; }; +/** + * Strips `undefined` and `null` from a union type so the *declared* shape can be + * inspected. An optional parameter (or property) resolves to + * ` | undefined`, and a union carries none of its members' type flags — + * so a test like "is this an object" or `checker.isArrayType` answers `false` for + * every optional slot unless the nullish part is removed first. A union that has + * more than one real member left is returned unchanged: there is no single + * declared shape to speak of then. + */ +export const nonNullishType = (type: ts.Type): ts.Type => { + if (!type.isUnion()) return type; + const nonNullish = type.types.filter( + (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0, + ); + return nonNullish.length === 1 ? nonNullish[0] : type; +}; + +/** + * Sets a schema's `suggestions` to the given set, dropping the key entirely when + * the set is empty or missing. Suggestion-carrying schemas are built with the + * key always present (an empty array when nothing matched), whereas the rest of + * the pipeline omits it — so every schema that is spread into a result passes + * through here instead of relying on the spread. + */ +export const withSuggestions = ( + schema: T, + suggestions: Input["suggestions"] | undefined, +): T => { + if (suggestions && suggestions.length > 0) return {...schema, suggestions}; + if (schema.suggestions === undefined) return schema; + const {suggestions: _dropped, ...rest} = schema; + return rest as T; +}; + +/** + * A declared (function-side) schema kept as the answer for its position, because + * the node side has no counterpart to merge into it — e.g. a property the entered + * value does not carry, or a union-typed position whose members no longer line up. + * + * The declared schema already carries the suggestions of every level it + * describes; only a fully generic slot inside it is rewritten to the constant + * `any` set, which is what the merge itself would have produced for such a slot + * (see {@link genericNodeSchema}). Empty suggestion arrays are dropped so the + * result follows the same convention as every other schema. + */ +export const declaredSchema = ( + schema: Schema, + anySuggestions: Input["suggestions"] = undefined, +): Schema => { + let result: Schema = schema; + + const items = (result as ListInput).items; + if (items) { + result = { + ...result, + items: items.map((s) => declaredSchema(s, anySuggestions)), + } as Schema; + } + + const declared = (result as ListInput).declaredItems; + if (declared) { + result = { + ...result, + declaredItems: declared.map((s) => declaredSchema(s, anySuggestions)), + } as Schema; + } + + const properties = (result as DataInput).properties; + if (properties) { + const mapped: Record = {}; + for (const [key, value] of Object.entries(properties)) { + mapped[key] = Array.isArray(value) + ? value.map((s) => declaredSchema(s, anySuggestions)) + : declaredSchema(value, anySuggestions); + } + result = {...result, properties: mapped} as Schema; + } + + return result.input === "generic" + ? withSuggestions(result, anySuggestions) + : withSuggestions(result, result.suggestions); +}; + /** * Treats a node-side schema as sitting in a fully generic ("accepts anything") * slot: the declared type constrains nothing here, so the value keeps its shape @@ -725,6 +889,24 @@ export const normalizeNodeSchema = (schema: Schema): Schema => { * type-parameter constraint — e.g. `keyof T` — so they are kept). Descendants are * always overridden regardless. */ +/** + * The declared element expansion of a list slot: the function-side `items`, each + * kept as the declared answer for its position (see {@link declaredSchema}). An + * element the declared type leaves unconstrained — a generic `LIST`, or a list + * in a slot with no declared type at all — yields a single generic entry carrying + * the "accepts anything" set, which is what such an element slot accepts. + * + * This is what {@link ListInput.declaredItems} carries, and it never depends on the + * entered value. + */ +export const declaredItemsOf = ( + functionItems: Schema[] | undefined, + anySuggestions: Input["suggestions"] = undefined, +): Schema[] => + functionItems && functionItems.length > 0 + ? functionItems.map((item) => declaredSchema(item, anySuggestions)) + : [withSuggestions({input: "generic"} as Schema, anySuggestions)]; + export const genericNodeSchema = ( schema: Schema, anySuggestions: Input["suggestions"] = undefined, @@ -740,6 +922,14 @@ export const genericNodeSchema = ( } as Schema; } + const declared = (result as ListInput).declaredItems; + if (declared) { + result = { + ...result, + declaredItems: declared.map((s) => genericNodeSchema(s, anySuggestions)), + } as Schema; + } + const properties = (result as DataInput).properties; if (properties) { const mapped: Record = {}; @@ -783,9 +973,26 @@ export const mergeSchemas = ( ); } - const suggestions = mergeSuggestions( - functionSchema.suggestions, - nodeSchema.suggestions, + // Suggestions answer "what may be put into this slot", and only the *declared* + // type decides that — never the value that happens to sit there. + // + // At the parameter root the node side is the authority: its suggestions were + // collected against the widened function parameter type (see + // `widenForSuggestions`), so a `T` slot still offers everything in scope after + // the current value narrowed `T` to, say, a boolean. + // + // At every nested position — a property of a `data`, an element of a `list` — + // the node side was collected against the concrete, value-narrowed type + // instead: a `TEXT` property holding "GET" would offer only the literal "GET" + // rather than everything that can produce a text. The function side carries the + // declared property/element type's own scope there, so it is the authority and + // the node side only fills in for positions the declared type does not + // describe. That keeps a nested slot's suggestions identical whether or not a + // value has been entered. + const suggestions = dedupeSuggestions( + topLevel + ? nodeSchema.suggestions + : (functionSchema.suggestions ?? nodeSchema.suggestions), ); if (functionSchema.input === "data") { @@ -797,19 +1004,14 @@ export const mergeSchemas = ( for (const key of keys) { properties[key] = mergeProperty(fProps[key], nProps[key], anySuggestions); } - return { - ...functionSchema, - properties, - ...(suggestions ? {suggestions} : {}), - }; + return withSuggestions({...functionSchema, properties}, suggestions); } // The generic list and every specialized list-* variant (list-select, // list-boolean/number/text, list-sub-flow) carry their element schemas in // `items`. Merge those pairwise so element-level suggestions — e.g. the // per-literal values on a list-select or the sub-flow function suggestions on - // a LIST> element — survive the merge instead of being dropped in - // favour of the suggestion-less function schema. Suggestions must never be + // a LIST> element — survive the merge. Suggestions must never be // lost, whatever the list kind. The node-side schema is always the plain // `list` kind (specialized variants are stripped via normalizeNodeSchema // before merging), so it is matched by kind family — any list input @@ -839,12 +1041,19 @@ export const mergeSchemas = ( ? nItems.map((n) => genericNodeSchema(n, anySuggestions)) : fItems.length === nItems.length && fItems.length > 0 ? fItems.map((f, i) => mergeSchemas(f, nItems[i], false, anySuggestions, false)) - : fItems; - return { - ...functionSchema, - items, - ...(suggestions ? {suggestions} : {}), - }; + // No node-side items to pair with (the value narrowed the list + // to something else, or expanded a union element to a different + // cardinality): the declared items stand on their own, carrying + // the suggestions of the element type they describe. + : declaredItemsOf(fItems, anySuggestions); + return withSuggestions( + { + ...functionSchema, + items, + declaredItems: declaredItemsOf(fItems, anySuggestions), + }, + suggestions, + ); } // A custom-input data type (e.g. TYPE) keeps its dedicated input, but a @@ -856,17 +1065,16 @@ export const mergeSchemas = ( // 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 withSuggestions( + { + ...functionSchema, + ...(nodeSchema.type !== undefined ? {type: nodeSchema.type} : {}), + }, + suggestions, + ); } - return { - ...functionSchema, - ...(suggestions ? {suggestions} : {}), - }; + return withSuggestions({...functionSchema}, suggestions); }; const mergeProperty = ( @@ -874,29 +1082,44 @@ const mergeProperty = ( n: Schema | Schema[] | undefined, anySuggestions: Input["suggestions"] = undefined, ): Schema | Schema[] => { - if (f && !Array.isArray(f) && n && !Array.isArray(n)) { - return mergeSchemas(f, n, false, anySuggestions, false); - } // Present only on the node side → the declared type does not constrain this // property, so it lives in a generic slot: keep the shape, use `any` - // suggestions. Present only on the function side → keep the declared schema. + // suggestions. if (f === undefined && n !== undefined) { return Array.isArray(n) ? n.map((s) => genericNodeSchema(s, anySuggestions)) : genericNodeSchema(n, anySuggestions); } - return (f ?? n)!; + if (f && !Array.isArray(f) && n && !Array.isArray(n)) { + return mergeSchemas(f, n, false, anySuggestions, false); + } + // A union-typed property carries one schema per member on both sides; merge + // them pairwise while the members still line up. + if (Array.isArray(f) && Array.isArray(n) && f.length === n.length) { + return f.map((member, index) => + mergeSchemas(member, n[index], false, anySuggestions, false), + ); + } + // Nothing on the node side to merge with, or the members no longer line up + // (e.g. the value narrowed a union property to one of its members): the + // declared schema stands on its own. + return Array.isArray(f) + ? f.map((member) => declaredSchema(member, anySuggestions)) + : declaredSchema(f!, anySuggestions); }; -const mergeSuggestions = ( - a: Input["suggestions"], - b: Input["suggestions"], +/** + * The suggestion set of a merged position: de-duplicated by structural equality, + * and `undefined` when nothing is left so the key is omitted rather than emitted + * empty. + */ +const dedupeSuggestions = ( + suggestions: Input["suggestions"], ): Input["suggestions"] | undefined => { - const all = [...(a ?? []), ...(b ?? [])]; - if (all.length === 0) return undefined; + if (!suggestions || suggestions.length === 0) return undefined; const seen = new Set(); const result: NonNullable = []; - for (const item of all) { + for (const item of suggestions) { const key = JSON.stringify(item); if (seen.has(key)) continue; seen.add(key); @@ -1229,6 +1452,34 @@ function isSelectType(type: ts.Type): boolean { return isPrimitiveLiteralUnion(type) || isStringOrNumberLiteral(type); } +/** + * Checks whether a union's members are the *options of one input* rather than + * alternative inputs: a string/number literal union (`'GET' | 'POST' | …`, which + * renders as a single select offering all of them) or `boolean` (internally the + * union `true | false`, which renders as a single boolean input). + * + * The distinction decides whether an entered value may narrow a union-typed slot + * to the single member it picked. For an options union it must not: narrowing + * `HTTP_METHOD` to the entered "GET" would hide the five other options of the + * very select the user is filling. For a union of alternative inputs + * (`COLOR | OBJECT<…>`, `TEXT | NUMBER`) it must: the member the value picked is + * the one input of the set that describes it, and it is also the entry the slot's + * {@link ListInput.declaredItems} already offers for it. + * + * @param type - The type to check + * @returns True if the type is a union whose members are options of one input + */ +export function isOptionsUnion(type: ts.Type): boolean { + if (!type.isUnion()) return false; + // `boolean` is `true | false` internally, so it is a union whose two members + // are the options of the one boolean input. + if (isBoolean(type)) return true; + const nonNullish = type.types.filter( + (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0 + ); + return nonNullish.length > 0 && nonNullish.every(isStringOrNumberLiteral); +} + /** * Checks if a type is a union of primitive types only. * diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 9b2a090..7c0e0a9 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -2283,11 +2283,227 @@ describe("Schema", () => { "gid://sagittarius/NodeFunction/1", ); - expect((result.parameters[0].schema as ListSubFlowInput)?.items?.[0]?.suggestions?.length).toBe(114) + // 114 sub-flow bindings plus the 9 node functions a callable slot + // now also offers (see "node-function suggestions on a callable + // (sub-flow) slot" below). + expect((result.parameters[0].schema as ListSubFlowInput)?.items?.[0]?.suggestions?.length).toBe(123) }); }); + // A callable (sub-flow) slot used to be the one position that got no node + // function suggestions at all: the collector behind them bailed out on a + // callable type, while the sub-flow collector bailed out on everything else, + // so the two were mutually exclusive. Both kinds are collected now. + describe("node-function suggestions on a callable (sub-flow) slot", () => { + /** The function identifiers a position suggests, for one suggestion kind. */ + const identifiersOf = (schema: any, typename: string): string[] => + ((schema?.suggestions ?? []) as any[]) + .filter((suggestion) => suggestion.__typename === typename) + .map((suggestion) => suggestion.functionDefinition?.identifier) + .sort(); + + /** The schema of one parameter of a single-node flow. */ + const parameterSchema = ( + identifier: string, + parameters: any[], + index: number, + functions: FunctionDefinition[] = FUNCTION_SIGNATURES, + ): any => { + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier}, + parameters: {nodes: parameters}, + }, + ], + }, + } as unknown as Flow; + + return getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/1", + ).parameters[index].schema as any; + }; + + const numbers = {__typename: "LiteralValue", value: [1, 2, 3]}; + + // The functions whose produced value is unconstrained — a bare type + // parameter, or `any`. A callable type is satisfied by nothing else, so + // these are exactly the node functions a sub-flow slot accepts. + const UNCONSTRAINED_PRODUCERS = [ + "std::control::return", + "std::control::value", + "std::list::at", + "std::list::find", + "std::list::find_last", + "std::list::first", + "std::list::last", + "std::list::pop", + ]; + + it("offers node functions next to the sub-flow bindings", () => { + const consumer = parameterSchema( + "std::list::for_each", + [{value: numbers}, {value: null}], + 1, + ); + + expect(consumer.input).toBe("sub-flow"); + expect(identifiersOf(consumer, "NodeFunction")).toEqual(UNCONSTRAINED_PRODUCERS); + // The node path does not widen the sub-flow path: the bindings a + // CONSUMER accepts are the same ones as before. + expect(identifiersOf(consumer, "SubFlowValue").length).toBe(56); + }); + + // Both kinds are offered for the same function definition — they are + // different actions (bind this function as the sub-flow vs. create a node + // that produces it), so consumers must tell them apart by `__typename` + // rather than by the function definition. + it("offers both kinds for the same function definition", () => { + const consumer = parameterSchema( + "std::list::for_each", + [{value: numbers}, {value: null}], + 1, + ); + + const asNode = identifiersOf(consumer, "NodeFunction"); + const asBinding = identifiersOf(consumer, "SubFlowValue"); + const inBoth = asNode.filter((identifier) => asBinding.includes(identifier)); + + expect(inBoth.length).toBeGreaterThan(0); + }); + + // The matching rule is the ordinary one — a function qualifies when the + // value it produces is assignable to the slot's type — applied to the + // callable type itself. The sub-flow's declared return type therefore does + // not narrow the candidates: a PREDICATE (`=> BOOLEAN`) offers the same + // node functions as a CONSUMER (`=> void`). Only the sub-flow bindings are + // scoped by the declared signature. + it("matches against the callable type, not the sub-flow's return type", () => { + const predicate = parameterSchema( + "std::list::filter", + [{value: numbers}, {value: null}], + 1, + ); + const consumer = parameterSchema( + "std::list::for_each", + [{value: numbers}, {value: null}], + 1, + ); + + expect(identifiersOf(predicate, "NodeFunction")).toEqual(UNCONSTRAINED_PRODUCERS); + expect(identifiersOf(predicate, "NodeFunction")).toEqual( + identifiersOf(consumer, "NodeFunction"), + ); + + // A BOOLEAN-producing function is not among them: it produces a + // boolean, not the `(item: T) => boolean` the slot declares. It is + // still offered as a binding, where the signature does match. + expect(identifiersOf(predicate, "NodeFunction")).not.toContain("std::boolean::negate"); + expect(identifiersOf(predicate, "SubFlowValue")).toContain("std::boolean::negate"); + }); + + it("offers them on a list's element slot as well", () => { + // A parameter whose element is an unconstrained callable. + const runAll: FunctionDefinition = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/950", + identifier: "test::flows::run_all", + signature: "(handlers: LIST<(...args: any): any>): void", + } as FunctionDefinition; + + const handlers = parameterSchema( + "test::flows::run_all", + [{value: null}], + 0, + [...FUNCTION_SIGNATURES, runAll], + ); + + expect(handlers.input).toBe("list-sub-flow"); + // Both the declared element expansion and the (here identical) items + // carry them — an element slot is where the sub-flow is actually filled. + for (const element of [handlers.declaredItems[0], handlers.items[0]]) { + expect(element.input).toBe("sub-flow"); + expect(identifiersOf(element, "NodeFunction")).toEqual([ + ...UNCONSTRAINED_PRODUCERS, + "test::flows::run_all", + ].sort()); + expect(identifiersOf(element, "SubFlowValue").length).toBeGreaterThan(0); + } + }); + + // An element slot scopes each of its options to the whole element type, + // and a union loses its call signatures as soon as one member is not + // callable. The bindings of a callable option must not depend on that: + // the sub-flow option of `FN | OBJECT<…>` offers exactly what the same + // element declared on its own does. + it("offers the same bindings on a callable option of a union element", () => { + const withSignature = (signature: string): FunctionDefinition[] => [ + ...FUNCTION_SIGNATURES, + { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/950", + identifier: "test::flows::run_all", + signature, + } as FunctionDefinition, + ]; + + const alone = parameterSchema( + "test::flows::run_all", + [{value: null}], + 0, + withSignature("(handlers: LIST<(...args: any[]) => any>): void"), + ); + const inUnion = parameterSchema( + "test::flows::run_all", + [{value: null}], + 0, + withSignature( + "(handlers: LIST<((...args: any[]) => any) | OBJECT<{test: NUMBER}>>): void", + ), + ); + + const optionOf = (schema: any, input: string): any => + schema.declaredItems.find((item: any) => item.input === input); + + const declared = alone.declaredItems[0]; + expect(identifiersOf(declared, "SubFlowValue").length).toBeGreaterThan(0); + expect(identifiersOf(optionOf(inUnion, "sub-flow"), "SubFlowValue")).toEqual( + identifiersOf(declared, "SubFlowValue"), + ); + // The items of a value-less list mirror the declared expansion, so the + // option carries them there as well. + expect( + identifiersOf( + inUnion.items.find((item: any) => item.input === "sub-flow"), + "SubFlowValue", + ), + ).toEqual(identifiersOf(declared, "SubFlowValue")); + // The object option is not callable, so it offers no bindings at all. + expect(identifiersOf(optionOf(inUnion, "data"), "SubFlowValue")).toEqual([]); + }); + + // Non-callable slots are untouched: they never hit the removed bail-out. + it("leaves a non-callable slot unchanged", () => { + const list = parameterSchema("std::list::at", [{value: null}, {value: null}], 0); + + expect(identifiersOf(list, "NodeFunction").length).toBe(22); + expect(identifiersOf(list, "SubFlowValue")).toEqual([]); + // The element slot accepts anything, so it keeps the full set — + // including the producers a callable slot rejects. + expect(identifiersOf(list.declaredItems[0], "NodeFunction").length).toBe(107); + expect(identifiersOf(list.declaredItems[0], "NodeFunction")).toContain("std::number::add"); + }); + }); + describe("union-typed property (string | nested object) reference suggestions", () => { // A custom datatype that is an object. One of its keys, `flexible`, is a // union of a plain string (TEXT) or a nested object. The nested object in @@ -2768,4 +2984,486 @@ describe("Schema", () => { }); }); -}) \ No newline at end of file + // Whether a parameter carries a value or not must never change *which* + // suggestions a position offers: the declared type decides that, and a value + // only drives the shape (how many items/properties, and each position's + // concrete `type`). Before, a value collapsed nested positions onto the + // node-side schema, which is scoped by the *narrowed* value type — so a nested + // list/object came out with no suggestions at all and a union-typed property + // with only the literal that had been entered. + describe("suggestion parity between an empty and a filled parameter", () => { + + // A concrete object data type: a select field, a text field, an open object + // field and a list field — one of each shape a nested position can take. + const TEST_REQUEST: DataType = { + __typename: "DataType", + id: "gid://sagittarius/DataType/9200", + identifier: "TEST_REQUEST", + genericKeys: [], + type: "{ http_method: HTTP_METHOD, url: HTTP_URL, headers: OBJECT<{}>, tags: LIST }", + } as unknown as DataType; + + // A union field whose members render as different inputs, so it must stay + // split into one schema per member (the select/boolean unions do not). + const TEST_MIXED: DataType = { + __typename: "DataType", + id: "gid://sagittarius/DataType/9201", + identifier: "TEST_MIXED", + genericKeys: [], + type: "{ flexible: TEXT | LIST }", + } as unknown as DataType; + + const fn = (identifier: string, signature: string): FunctionDefinition => + ({ + __typename: "FunctionDefinition", + id: `gid://sagittarius/FunctionDefinition/92${identifier.length}`, + identifier, + signature, + }) as FunctionDefinition; + + const functions = [ + ...FUNCTION_SIGNATURES, + fn("test::parity::required", "(request: TEST_REQUEST): void"), + fn("test::parity::optional", "(request?: TEST_REQUEST): void"), + fn("test::parity::methods", "(methods: LIST): void"), + fn("test::parity::matrix", "(matrix: LIST>): void"), + fn("test::parity::mixed", "(mixed: TEST_MIXED): void"), + // A list whose element is a union of *alternative inputs*: a COLOR + // (dedicated input, structurally an object) or a plain object. + fn("test::parity::either", "(items: LIST>): void"), + // The same union in a scalar slot. + fn("test::parity::either_one", "(item: COLOR | OBJECT<{test: NUMBER}>): void"), + fn("test::parity::objects", "(objects: LIST>): void"), + fn("test::parity::texts_or_numbers", "(values: LIST): void"), + fn("test::parity::flags", "(flags: LIST): void"), + ]; + const dataTypes = [...DATA_TYPES, TEST_REQUEST, TEST_MIXED]; + + /** The sole parameter's schema of a single node calling `identifier` with `value`. */ + const probe = (identifier: string, value: unknown): any => { + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier}, + parameters: { + nodes: [ + { + value: + value === undefined + ? null + : {__typename: "LiteralValue", value}, + }, + ], + }, + }, + ], + }, + } as unknown as Flow; + + return getSignatureSchema( + flow, + dataTypes, + functions, + "gid://sagittarius/NodeFunction/1", + ).parameters[0].schema; + }; + + /** A position's suggestions as a comparable, order-independent set. */ + const suggestionSet = (schema: any): Set => + new Set(((schema?.suggestions ?? []) as unknown[]).map((s) => JSON.stringify(s))); + + /** The literal options a position offers, by their value. */ + const literalOptions = (schema: any): unknown[] => + ((schema?.suggestions ?? []) as any[]) + .filter((suggestion) => "value" in suggestion) + .map((suggestion) => suggestion.value); + + const property = (schema: any, key: string): any => { + const prop = schema.properties?.[key]; + return Array.isArray(prop) ? prop[0] : prop; + }; + + const REQUEST_VALUE = { + http_method: "GET", + url: "/test", + headers: {"x-trace": "1"}, + tags: ["a", "b"], + }; + + it("keeps a nested object property's own suggestions when a value is entered", () => { + const empty = probe("test::parity::required", undefined); + const filled = probe("test::parity::required", REQUEST_VALUE); + + const emptyHeaders = property(empty, "headers"); + const filledHeaders = property(filled, "headers"); + + expect(emptyHeaders.input).toBe("data"); + expect(filledHeaders.input).toBe("data"); + // The entered field is mirrored... + expect(Object.keys(filledHeaders.properties)).toEqual(["x-trace"]); + // ...while the object slot itself keeps offering what can produce it. + expect(suggestionSet(emptyHeaders).size).toBeGreaterThan(0); + expect(suggestionSet(filledHeaders)).toEqual(suggestionSet(emptyHeaders)); + }); + + it("keeps a nested list property's own suggestions when a value is entered", () => { + const empty = probe("test::parity::required", undefined); + const filled = probe("test::parity::required", REQUEST_VALUE); + + const emptyTags = property(empty, "tags"); + const filledTags = property(filled, "tags"); + + // One item per entered element, but the same list-level suggestions. + expect(filledTags.items).toHaveLength(2); + expect(suggestionSet(emptyTags).size).toBeGreaterThan(0); + expect(suggestionSet(filledTags)).toEqual(suggestionSet(emptyTags)); + + // ...and the same element-level suggestions on every item. + const emptyItem = suggestionSet(emptyTags.items[0]); + expect(emptyItem.size).toBeGreaterThan(0); + filledTags.items.forEach((item: any) => + expect(suggestionSet(item)).toEqual(emptyItem), + ); + }); + + it("keeps a select property one input carrying every option, filled or not", () => { + const empty = probe("test::parity::required", undefined); + const filled = probe("test::parity::required", REQUEST_VALUE); + + // A literal union is a single select — not one input per option. + expect(Array.isArray(empty.properties.http_method)).toBe(false); + expect(empty.properties.http_method.input).toBe("select"); + expect(filled.properties.http_method.input).toBe("select"); + + expect(literalOptions(empty.properties.http_method)).toEqual( + ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + ); + // Entering "GET" must not reduce the options to the entered one. + expect(suggestionSet(filled.properties.http_method)).toEqual( + suggestionSet(empty.properties.http_method), + ); + }); + + it("expands an optional object parameter's entered fields like a required one", () => { + const optional = probe("test::parity::optional", REQUEST_VALUE); + const required = probe("test::parity::required", REQUEST_VALUE); + + // `request?: TEST_REQUEST` resolves to `TEST_REQUEST | undefined`, whose + // union flags carry no object bit — the entered fields used to vanish. + expect(Object.keys(optional.properties)).toEqual(Object.keys(required.properties)); + expect(suggestionSet(property(optional, "headers"))).toEqual( + suggestionSet(property(required, "headers")), + ); + expect(property(optional, "tags").items).toHaveLength(2); + }); + + it("offers every option on each element of a list-select, filled or not", () => { + const empty = probe("test::parity::methods", undefined); + const filled = probe("test::parity::methods", ["GET", "POST"]); + + expect(empty.input).toBe("list-select"); + expect(filled.input).toBe("list-select"); + expect(filled.items).toHaveLength(2); + + // An item of the empty list describes one *option* of the element slot, + // so it is scoped to the whole element type — the same set an entered + // element gets. + const options = suggestionSet(empty.items[0]); + expect(literalOptions(empty.items[0])).toEqual( + ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + ); + empty.items.forEach((item: any) => expect(suggestionSet(item)).toEqual(options)); + filled.items.forEach((item: any) => expect(suggestionSet(item)).toEqual(options)); + + // The whole-list slot keeps its own suggestions either way. + expect(suggestionSet(filled)).toEqual(suggestionSet(empty)); + }); + + it("keeps a nested list's suggestions inside a value-driven list", () => { + const empty = probe("test::parity::matrix", undefined); + const filled = probe("test::parity::matrix", [["GET"], ["POST"]]); + + const emptyInner = empty.items[0]; + expect(emptyInner.input).toBe("list-select"); + expect(suggestionSet(emptyInner).size).toBeGreaterThan(0); + + expect(filled.items).toHaveLength(2); + filled.items.forEach((inner: any) => { + expect(inner.input).toBe("list-select"); + expect(suggestionSet(inner)).toEqual(suggestionSet(emptyInner)); + expect(suggestionSet(inner.items[0])).toEqual(suggestionSet(emptyInner.items[0])); + }); + }); + + it("still splits a property whose union members render as different inputs", () => { + const empty = probe("test::parity::mixed", undefined); + + // `TEXT | LIST` has no single input kind, so it stays one schema + // per member — each with the suggestions of its own member type. + const members = empty.properties.flexible; + expect(Array.isArray(members)).toBe(true); + expect(members.map((m: any) => m.input).sort()).toEqual(["list-text", "text"]); + members.forEach((member: any) => + expect(suggestionSet(member).size).toBeGreaterThan(0), + ); + }); + + // `items` is value-driven on purpose, so on its own it cannot answer "what + // may an element be": an entered value reduces it to the elements that are + // there (and `[]` empties it entirely), which changes the element schemas — + // and with them the cumulated element suggestions — of the very same slot. + // `declaredItems` carries that declared answer next to it, always. + describe("declaredItems", () => { + + it("carries the element options unchanged, empty, filled or emptied", () => { + const empty = probe("test::parity::methods", undefined); + const filled = probe("test::parity::methods", ["GET", "POST"]); + const emptied = probe("test::parity::methods", []); + + // The declared expansion is the same in all three cases... + expect(filled.declaredItems).toEqual(empty.declaredItems); + expect(emptied.declaredItems).toEqual(empty.declaredItems); + expect(empty.declaredItems.map((item: any) => item.input)).toEqual( + Array(6).fill("select"), + ); + expect(literalOptions(empty.declaredItems[0])).toEqual( + ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + ); + + // ...while `items` follows the value. + expect(empty.items).toHaveLength(6); + expect(filled.items).toHaveLength(2); + expect(emptied.items).toHaveLength(0); + + // The cumulated element suggestions are therefore stable, even for + // the emptied list that has no `items` left to read them from. + const cumulated = (schema: any): Set => + new Set( + (schema.declaredItems as any[]).flatMap((item) => [ + ...suggestionSet(item), + ]), + ); + expect(cumulated(filled)).toEqual(cumulated(empty)); + expect(cumulated(emptied)).toEqual(cumulated(empty)); + }); + + it("is present at every level of a nested list", () => { + const filled = probe("test::parity::matrix", [["GET"], ["POST"]]); + const empty = probe("test::parity::matrix", undefined); + + // Outer level: one declared entry, the inner list. + expect(filled.declaredItems).toEqual(empty.declaredItems); + expect(filled.declaredItems[0].input).toBe("list-select"); + + // Inner level: the six options, on the entered inner lists too. + expect(filled.declaredItems[0].declaredItems).toEqual( + empty.declaredItems[0].declaredItems, + ); + filled.items.forEach((inner: any) => + expect(inner.declaredItems).toEqual(empty.declaredItems[0].declaredItems), + ); + }); + + it("describes an unconstrained element slot as one generic entry", () => { + // `(value: T)` — the slot declares nothing about its elements, so + // the declared answer is "anything", with the constant `any` set. + const generic = probe("std::control::value", [1, 2]); + + expect(generic.input).toBe("list"); + expect(generic.items.map((item: any) => item.input)).toEqual(["number", "number"]); + expect(generic.declaredItems).toHaveLength(1); + expect(generic.declaredItems[0].input).toBe("generic"); + expect(suggestionSet(generic.declaredItems[0])).toEqual( + suggestionSet(generic.items[0]), + ); + }); + + // An element of a union-typed list is one of the union's members, so + // `items[i]` must be the schema of that member — the same entry + // `declaredItems` already offers for it, refined by the value — and not + // a shape read off the raw value. Before this, the union (which carries + // none of its members' type flags) read as an unconstrained slot: a + // COLOR value was expanded back into the `{hue, saturation, lightness}` + // object its dedicated input replaces, and an object value lost the + // declared property schemas the member describes. + describe("union-typed element", () => { + + const COLOR_VALUE = {hue: 210, saturation: 50, lightness: 40}; + + it("renders each entered element as the declared member it picked", () => { + const filled = probe("test::parity::either", [COLOR_VALUE, {test: 1}]); + + expect(filled.items.map((item: any) => item.input)).toEqual(["color", "data"]); + expect(filled.items[1].properties.test.input).toBe("number"); + expect(filled.items[1].required).toEqual(["test"]); + }); + + it("picks the member per element, whatever the element count", () => { + // The declared union has two members and the value three + // elements: cardinality comes from the value, the member from + // each element. + const filled = probe("test::parity::either", [COLOR_VALUE, {test: 1}, COLOR_VALUE]); + + expect(filled.items.map((item: any) => item.input)) + .toEqual(["color", "data", "color"]); + }); + + it("offers both members as the element's options, filled or not", () => { + const empty = probe("test::parity::either", undefined); + const filled = probe("test::parity::either", [COLOR_VALUE]); + + // The declared answer enumerates the union's members... + expect(empty.declaredItems.map((item: any) => item.input)) + .toEqual(["color", "data"]); + expect(filled.declaredItems).toEqual(empty.declaredItems); + + // ...and every entered element is one of them, with that + // member's own suggestions. + const declaredKinds = empty.declaredItems.map((item: any) => item.input); + filled.items.forEach((item: any) => { + expect(declaredKinds).toContain(item.input); + expect(suggestionSet(item)).toEqual( + suggestionSet( + empty.declaredItems.find((d: any) => d.input === item.input), + ), + ); + }); + }); + + it("identifies the member of a half-filled element", () => { + // `null` is not assignable to NUMBER, so the member is found by + // the keys the value names — an unfilled field must not cost the + // element its declared schema. + const filled = probe("test::parity::either", [{test: null}]); + + expect(filled.items).toHaveLength(1); + expect(filled.items[0].input).toBe("data"); + expect(filled.items[0].properties.test.input).toBe("number"); + }); + + it("narrows a scalar union slot to the member the value picked", () => { + expect(probe("test::parity::either_one", COLOR_VALUE).input).toBe("color"); + + const object = probe("test::parity::either_one", {test: 1}); + expect(object.input).toBe("data"); + expect(object.properties.test.input).toBe("number"); + }); + + it("renders a primitive union element as the member's own input", () => { + const empty = probe("test::parity::texts_or_numbers", undefined); + const filled = probe("test::parity::texts_or_numbers", ["a", 1]); + + // `TEXT | NUMBER` are two inputs, not two options of one: the + // entered elements render as the member each picked — the same + // kinds the empty slot offers. + expect(empty.items.map((item: any) => item.input)).toEqual(["text", "number"]); + expect(filled.items.map((item: any) => item.input)).toEqual(["text", "number"]); + expect(filled.declaredItems).toEqual(empty.declaredItems); + }); + + it("leaves an options union unnarrowed", () => { + // A literal union is the option list of a single select, and + // `boolean` is `true | false` — entering one member must not hide + // the others. + const methods = probe("test::parity::methods", ["GET"]); + expect(methods.items[0].input).toBe("select"); + expect(literalOptions(methods.items[0])) + .toEqual(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]); + + const flags = probe("test::parity::flags", [true]); + const emptyFlags = probe("test::parity::flags", undefined); + expect(flags.items[0].input).toBe("boolean"); + expect(suggestionSet(flags.items[0])).toEqual(suggestionSet(emptyFlags.items[0])); + }); + }); + + it("keeps a declared property's own type when its value is null", () => { + // An unfilled field narrows nothing: the property stays the number + // input of type number it is declared as, rather than being + // retyped to the `null` that sits in it. + const filled = probe("test::parity::objects", [{test: null}]); + const empty = probe("test::parity::objects", undefined); + + expect(filled.items[0].properties.test).toEqual(empty.items[0].properties.test); + expect(filled.items[0].properties.test.input).toBe("number"); + expect(filled.items[0].properties.test.type).toBe("number"); + }); + + it("is omitted on a schema that describes a produced value", () => { + // A type schema (like a signature's return) is type-driven through + // and through: `items` is already the declared expansion there. + const typeSchema = getTypeSchema("LIST", DATA_TYPES) as any; + + expect(typeSchema.items).toHaveLength(6); + expect(typeSchema.declaredItems).toBeUndefined(); + }); + }); + + // A value only ever refines the declared object: the fields it does not + // mention keep their declared schema instead of disappearing, which is what + // lets the UI render (and suggest for) the fields still to be filled. + describe("partially filled object", () => { + + it("keeps every declared field, with the declared required list", () => { + const empty = probe("test::parity::required", undefined); + const partial = probe("test::parity::required", {url: "/x"}); + + expect(Object.keys(partial.properties)).toEqual(Object.keys(empty.properties)); + expect(partial.required).toEqual(empty.required); + + // The untouched fields are identical to the empty case, suggestions + // included; only the entered `url` takes the value's `type`. + expect(partial.properties.http_method).toEqual(empty.properties.http_method); + expect(partial.properties.tags).toEqual(empty.properties.tags); + expect(partial.properties.url.input).toBe("text"); + expect(suggestionSet(partial.properties.url)).toEqual( + suggestionSet(empty.properties.url), + ); + }); + + it("adds a field the declared type does not mention", () => { + const partial = probe("test::parity::required", {url: "/x", extra: 1}); + + // The extra field is unconstrained → its own shape, `any` suggestions. + expect(partial.properties.extra.input).toBe("number"); + expect(suggestionSet(partial.properties.extra).size).toBeGreaterThan(0); + // ...and it is not part of what the declared type requires. + expect(partial.required).not.toContain("extra"); + }); + }); + + it("never emits an empty suggestions array", () => { + // Suggestion-carrying schemas are built with the key always present; + // the published schema omits it instead of exposing an empty list. + const seen: string[] = []; + const walk = (schema: any, path: string): void => { + if (Array.isArray(schema?.suggestions) && schema.suggestions.length === 0) { + seen.push(path); + } + (schema?.items ?? []).forEach((item: any, index: number) => + walk(item, `${path}.items[${index}]`), + ); + (schema?.declaredItems ?? []).forEach((item: any, index: number) => + walk(item, `${path}.declaredItems[${index}]`), + ); + for (const [key, value] of Object.entries(schema?.properties ?? {})) { + (Array.isArray(value) ? value : [value]).forEach((member, index) => + walk(member, `${path}.${key}[${index}]`), + ); + } + }; + + walk(probe("test::parity::required", REQUEST_VALUE), "request"); + walk(probe("test::parity::matrix", [["GET"], ["POST"]]), "matrix"); + walk(probe("test::parity::optional", REQUEST_VALUE), "optional"); + + expect(seen).toEqual([]); + }); + }); + +})