diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index a498239..c8b4f88 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 {getSchema, mergeSchemas, Schema} from "../util/schema.util" +import {genericNodeSchema, getSchema, mergeSchemas, normalizeNodeSchema, Schema} from "../util/schema.util" /** * Represents the schema information for a node parameter. @@ -398,8 +398,21 @@ const generateNodeSchemas = ( return [] } + // The suggestion set for a fully generic ("accepts anything") slot. Every + // position the declared type leaves unconstrained offers this same set, + // independent of the concrete value entered there — computed once against `any`. + const anySuggestions = getSchema( + checker, + node, + checker.getAnyType(), + Array.from(declaredFunctionsMap.values()), + functions, + true, + ).suggestions + return nodeParameterTypes.map((parameterType, index) => { const functionParameterType = functionParameterTypes?.[index] + // Suggestions are scoped by what the *function* parameter accepts (e.g. // `T` widens to `any`, so anything in scope is a valid candidate), even // when the node value has narrowed the actual parameter type — otherwise @@ -409,31 +422,102 @@ const generateNodeSchemas = ( ? widenForSuggestions(checker, functionParameterType, node!) : undefined - const nodeSchema = getSchema( - checker, - node, - parameterType, - Array.from(declaredFunctionsMap.values()), - functions, - true, - suggestionType, - ) + // Value-driven list items: when the argument is an array literal, the + // list renders exactly one item per entered element (like an object's + // properties mirror its fields). The item's input kind and select + // options come from the declared element type; the item's `type` is the + // concrete value's base type (e.g. "string"). This overrides the + // type-driven, union-expanded items a plain type analysis would produce. + // The whole-list suggestions (references/nodes that produce a matching + // list) are still surfaced, scoped by what the function accepts. + // The same value-driven cardinality holds for an object literal: each + // 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()) const functionSchema = functionParameterType ? getSchema( checker, node, functionParameterType, - Array.from(declaredFunctionsMap.values()), + functionDeclarations, functions, false ) : undefined + // An object literal is only expanded value-first when the node's resolved + // parameter type is genuinely an object. Against a scalar slot the `{}` + // value is a type mismatch (or a conditional that collapsed to a scalar), + // so the schema must follow the resolved kind — it falls through to the + // 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) + + const argExpr = getArgumentExpression(node, index) + if ( + argExpr && + (ts.isArrayLiteralExpression(argExpr) || + (ts.isObjectLiteralExpression(argExpr) && nodeTypeIsObject)) + ) { + const wholeSuggestions = getSchema( + checker, + node, + parameterType, + functionDeclarations, + functions, + true, + suggestionType, + ).suggestions + return { + schema: ts.isArrayLiteralExpression(argExpr) + ? buildValueDrivenListSchema( + checker, + node, + functionParameterType, + argExpr, + functionDeclarations, + functions, + wholeSuggestions, + anySuggestions, + ) + : buildValueDrivenObjectSchema( + checker, + node, + functionParameterType, + argExpr, + functionDeclarations, + functions, + wholeSuggestions, + anySuggestions, + ), + blockedBy: funktionDependencies + .filter((dep) => dep.parameterIndex === index) + .map((dep) => dep.dependsOnIndex), + } + } + + // Specialized list-* inputs are a declared-type concern; the node value + // only contributes concrete element types, so normalize what it produced. + const nodeSchema = normalizeNodeSchema(getSchema( + checker, + node, + parameterType, + functionDeclarations, + functions, + true, + suggestionType, + )) + return { schema: mergeSchemas( functionSchema, nodeSchema, valueProvidedByIndex[index] ?? false, + anySuggestions, ), blockedBy: funktionDependencies .filter((dep) => dep.parameterIndex === index) @@ -442,6 +526,213 @@ const generateNodeSchemas = ( }) } +/** + * Returns the argument expression at the given position of the node's call + * expression, or undefined when the node has no call initializer or fewer + * arguments. + */ +const getArgumentExpression = ( + node: ts.VariableDeclaration, + index: number, +): ts.Expression | undefined => { + if (!node.initializer || !ts.isCallExpression(node.initializer)) return undefined + return node.initializer.arguments[index] +} + +// Primitive item kinds whose schema is rebuilt value-first: the declared kind is +// kept, but the item's `type` comes from the concrete value while the declared +// element type's suggestions (options, references, nodes) are carried along. +const PRIMITIVE_ITEM_INPUTS = new Set(["select", "boolean", "number", "text"]) + +/** + * Builds a value-driven list schema from an array-literal argument. + * + * The list kind (e.g. `list`, `list-select`) comes from the declared function + * list type, while `items` has exactly one entry per entered element (like an + * object's properties mirror its fields). Nested array literals recurse, so the + * per-value cardinality holds at every level. Whole-list suggestions (what can + * produce the list) are attached when provided. + */ +const buildValueDrivenListSchema = ( + checker: ts.TypeChecker, + node: ts.VariableDeclaration, + funcListType: Type | undefined, + arrayExpr: ts.ArrayLiteralExpression, + functionDeclarations: ts.FunctionDeclaration[], + functions: FunctionDefinition[], + suggestions?: Schema["suggestions"], + anySuggestions?: Schema["suggestions"], +): Schema => { + const funcSchema = funcListType + ? getSchema(checker, node, funcListType, functionDeclarations, functions, false) + : undefined + const isListKind = + funcSchema != null && + (funcSchema.input as string | undefined)?.startsWith("list") === true + const funcElementType = + funcListType && checker.isArrayType(funcListType) + ? checker.getTypeArguments(funcListType 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 +} + +/** + * Builds a single list item schema for one array-literal element. + * + * The item's input kind comes from the declared element type; its `type` is the + * concrete value's base type (literals widened, e.g. "GET" → string). Each item + * carries the full suggestions of its element slot — literal options (e.g. a + * select's members or true/false for a boolean), in-scope references, and + * compatible function nodes. A generic declared element lets the value drive kind + * and type; a nested array literal recurses into a value-driven list; a + * structured element (object, …) keeps its declared schema and only contributes + * to the item count. + */ +const buildValueDrivenItem = ( + checker: ts.TypeChecker, + node: ts.VariableDeclaration, + funcElementType: Type | undefined, + element: ts.Expression, + functionDeclarations: ts.FunctionDeclaration[], + functions: FunctionDefinition[], + anySuggestions?: Schema["suggestions"], +): Schema => { + if (ts.isArrayLiteralExpression(element)) { + return buildValueDrivenListSchema(checker, node, funcElementType, 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) + } + + const funcElementSchema = funcElementType + ? getSchema(checker, node, funcElementType, functionDeclarations, functions, true) + : undefined + const funcIsGeneric = !funcElementSchema || funcElementSchema.input === "generic" + const valueType = checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(element)) + + // Generic declared element: the value drives kind and type, but the slot + // accepts anything, so the suggestions are the constant `any` set — not the + // subset the concrete value would narrow to. + if (funcIsGeneric) { + return genericNodeSchema( + getSchema(checker, node, valueType, functionDeclarations, functions, true), + anySuggestions, + ) + } + + // Primitive/select element: keep the declared kind and suggestions, but take + // the concrete value's base type as the item type. + if (PRIMITIVE_ITEM_INPUTS.has(funcElementSchema!.input as string)) { + return { + ...funcElementSchema!, + type: checker.typeToString(valueType), + } as Schema + } + + // Structured element (object, …): keep the declared schema as-is. + return funcElementSchema! +} + +/** + * 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 + * 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 + * the shape while carrying the constant `any` suggestions. Whole-object + * suggestions (references/nodes that produce a matching object) are attached when + * provided. + */ +const buildValueDrivenObjectSchema = ( + checker: ts.TypeChecker, + node: ts.VariableDeclaration, + funcObjectType: Type | undefined, + objectExpr: ts.ObjectLiteralExpression, + functionDeclarations: ts.FunctionDeclaration[], + functions: FunctionDefinition[], + suggestions?: Schema["suggestions"], + anySuggestions?: Schema["suggestions"], +): Schema => { + const funcSchema = funcObjectType + ? getSchema(checker, node, funcObjectType, functionDeclarations, functions, false) + : undefined + const isDataKind = funcSchema?.input === "data" + + const properties: Record = {} + const required: 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() + // 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) + : undefined + properties[key] = buildValueDrivenItem( + checker, + node, + funcPropertyType, + property.initializer, + functionDeclarations, + functions, + anySuggestions, + ) + required.push(key) + } + + return { + input: "data", + type: + (isDataKind ? funcSchema!.type : undefined) ?? + checker.typeToString(checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr))), + properties, + required, + ...(suggestions?.length ? {suggestions} : {}), + } as Schema +} + +/** + * 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 + * declared object does not constrain). + */ +const getObjectPropertyType = ( + checker: ts.TypeChecker, + objectType: Type, + key: string, +): Type | undefined => { + const symbol = checker.getPropertyOfType(objectType, key) + if (!symbol) return undefined + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] + return declaration + ? checker.getTypeOfSymbolAtLocation(symbol, declaration) + : undefined +} + // Widen a function parameter type so that suggestion collection asks "what could // the function accept here", not "what does the current value narrow this to". // An unconstrained type parameter accepts anything → `any`. A constrained type diff --git a/src/util/nodes.util.ts b/src/util/nodes.util.ts index 331efe0..fb663f0 100644 --- a/src/util/nodes.util.ts +++ b/src/util/nodes.util.ts @@ -69,9 +69,6 @@ const createNodeFunctionIfCompatible = ( paramType: ts.Type ): NodeFunction | null => { - if (func.parameters.length > 0) - return null; - // Extract the function signature and its return type const signature = checker.getSignatureFromDeclaration(func); const returnType = checker.getReturnTypeOfSignature(signature!); diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 894724d..6fefaae 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -349,12 +349,17 @@ export const getSchema = ( // 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. + // The recursion keeps the caller's `suggestions` flag so nested members of the + // stripped type still get their own suggestions (e.g. an optional object + // `OBJ | undefined` must expose the same per-property suggestions as a required + // `OBJ`). Only this union node's own top-level suggestions are replaced by + // `combinedSuggestions`, which was scoped by the original (nullable) type. if (parameterType.isUnion()) { const nonNullish = parameterType.types.filter( (t) => (t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)) === 0 ) if (nonNullish.length === 1) { - const baseSchema = getSchema(checker, node, nonNullish[0], functionDeclarations, functions, false, undefined, visited, recursionCache) + const baseSchema = getSchema(checker, node, nonNullish[0], functionDeclarations, functions, suggestions, undefined, visited, recursionCache) return {...baseSchema, type, ...combinedSuggestions} } } @@ -592,17 +597,140 @@ const LIST_INPUTS = new Set([ "list-sub-flow", ]); +// The specialized list-* input kinds. These express a UI intention (render a +// dedicated multi-/multi-select/… control) that is only meaningful +// when the *declared* function parameter type asks for it. A concrete node value +// must never surface one of these — it should only contribute the concrete +// element types. list-file is included even though it carries no `items`. +const SPECIALIZED_LIST_INPUTS = new Set([ + "list-select", + "list-boolean", + "list-number", + "list-text", + "list-sub-flow", + "list-file", +]); + +/** + * Normalizes a node-side schema so it only contributes concrete resolved types + * to {@link mergeSchemas}, recursing through `items` and `properties`. It: + * + * - Rewrites every specialized list-* input back to the plain `list` kind. The + * specialized variants are a declared-type (function-side) concern, so a + * concrete value must never surface one; the resolved element types (via + * `items`) are kept, and the `list-file` `mimetype` is dropped because a plain + * list has no such field. + * - Drops an empty `suggestions` array. Node schemas are built with suggestions + * enabled and therefore always carry the key — even when empty — whereas the + * rest of the pipeline omits it entirely when there are none. + * + * The function-side schema still drives the final input kind in + * {@link mergeSchemas}. + */ +export const normalizeNodeSchema = (schema: Schema): Schema => { + let result: Schema = schema; + + const items = (schema as ListInput).items; + if (items) { + result = {...result, items: items.map(normalizeNodeSchema)} as Schema; + } + + const properties = (schema as DataInput).properties; + if (properties) { + const mapped: Record = {}; + for (const [key, value] of Object.entries(properties)) { + mapped[key] = Array.isArray(value) + ? value.map(normalizeNodeSchema) + : normalizeNodeSchema(value); + } + result = {...result, properties: mapped} as Schema; + } + + if (SPECIALIZED_LIST_INPUTS.has(result.input as string)) { + const {mimetype, ...rest} = result as ListInput & {mimetype?: string}; + result = {...rest, input: "list"}; + } + + if (result.suggestions && result.suggestions.length === 0) { + const {suggestions, ...rest} = result; + result = rest; + } + + return result; +}; + +/** + * 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 + * while a select narrowed from a single literal is demoted to its free-form + * primitive. Recurses through `items` and `properties` so the whole subtree is + * treated uniformly. + * + * Suggestions: nested positions were built value-scoped (the recursion inside + * {@link getSchema} drops the suggestion scope), so they are replaced with the + * constant `any` set. The `overrideRoot` flag controls this for the top node + * only: at a nested position it is `true` (the node's own suggestions are the + * value-narrowed subset and must be replaced); at a parameter root it is `false` + * (the node's suggestions were already scoped by `suggestionType`/the + * type-parameter constraint — e.g. `keyof T` — so they are kept). Descendants are + * always overridden regardless. + */ +export const genericNodeSchema = ( + schema: Schema, + anySuggestions: Input["suggestions"] = undefined, + overrideRoot: boolean = true, +): Schema => { + let result = demoteSelect(schema); + + const items = (result as ListInput).items; + if (items) { + result = { + ...result, + items: items.map((s) => genericNodeSchema(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) => genericNodeSchema(s, anySuggestions)) + : genericNodeSchema(value, anySuggestions); + } + result = {...result, properties: mapped} as Schema; + } + + if (overrideRoot) { + if (anySuggestions && anySuggestions.length > 0) { + result = {...result, suggestions: anySuggestions}; + } else if (result.suggestions) { + const {suggestions, ...rest} = result; + result = rest; + } + } + + return result; +}; + export const mergeSchemas = ( functionSchema: Schema | undefined, nodeSchema: Schema, valueProvided: boolean = false, + anySuggestions: Input["suggestions"] = undefined, + topLevel: boolean = true, ): Schema => { - if (!functionSchema) { - return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided); - } - - if (functionSchema.input === "generic") { - return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided); + // A function-less or fully generic slot constrains nothing here: the value + // drives the shape, and nested levels take the constant `any` set (see + // genericNodeSchema), never the value-narrowed subset. The root's own + // suggestions are kept only at the parameter top level, where they were + // already scoped by the type-parameter constraint (e.g. `keyof T` for a + // `key: K` slot); a nested generic hit is value-scoped and gets overridden. + if (!functionSchema || functionSchema.input === "generic") { + return liftGenericIfValued( + genericNodeSchema(nodeSchema, anySuggestions, !topLevel), + valueProvided, + ); } const suggestions = mergeSuggestions( @@ -617,9 +745,7 @@ export const mergeSchemas = ( const properties: Record = {}; const keys = new Set([...Object.keys(fProps), ...Object.keys(nProps)]); for (const key of keys) { - const f = fProps[key]; - const n = nProps[key]; - properties[key] = mergeProperty(f, n); + properties[key] = mergeProperty(fProps[key], nProps[key], anySuggestions); } return { ...functionSchema, @@ -634,18 +760,36 @@ export const mergeSchemas = ( // 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 - // lost, whatever the list kind. Only merges when both sides are the same list - // kind. (list-file carries a `mimetype`, not `items`, so it is not listed.) + // 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 + // contributes its items — rather than requiring an exact kind match with the + // function schema. if (LIST_INPUTS.has(functionSchema.input as string)) { const fItems = (functionSchema as ListInput).items ?? []; const nItems = - nodeSchema.input === functionSchema.input + LIST_INPUTS.has(nodeSchema.input as string) ? ((nodeSchema as ListInput).items ?? []) : []; + // A generic function element (LIST → a single `{input: "generic"}` + // item) carries no structure, so the node's concrete element schemas win + // outright. Their cardinality may differ from the function's single + // placeholder — e.g. LIST expands its element to `true | false`, + // yielding two item schemas — which is why a strict pairwise merge cannot + // be used here. When the function element is itself concrete (e.g. + // LIST → one select per literal), the counts line up and the + // items are merged pairwise so element-level suggestions survive. + const fAllGeneric = + fItems.length > 0 && fItems.every((it) => it.input === "generic"); + // A generic function element leaves each item unconstrained, so the + // node's concrete items keep their shape but take the `any` suggestion + // set. A concrete function element merges pairwise so its own scope wins. const items = - fItems.length === nItems.length && fItems.length > 0 - ? fItems.map((f, i) => mergeSchemas(f, nItems[i])) - : fItems; + fAllGeneric && nItems.length > 0 + ? 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, @@ -662,9 +806,18 @@ export const mergeSchemas = ( const mergeProperty = ( f: Schema | Schema[] | undefined, n: Schema | Schema[] | undefined, + anySuggestions: Input["suggestions"] = undefined, ): Schema | Schema[] => { if (f && !Array.isArray(f) && n && !Array.isArray(n)) { - return mergeSchemas(f, 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. + if (f === undefined && n !== undefined) { + return Array.isArray(n) + ? n.map((s) => genericNodeSchema(s, anySuggestions)) + : genericNodeSchema(n, anySuggestions); } return (f ?? n)!; }; diff --git a/src/validation/getFlowValidation.ts b/src/validation/getFlowValidation.ts index a60b763..f3635c9 100644 --- a/src/validation/getFlowValidation.ts +++ b/src/validation/getFlowValidation.ts @@ -4,6 +4,7 @@ import {createCompilerHost, generateFlowSourceCode, ValidationResult} from "../u // TypeScript diagnostic codes we may soften into warnings for union-branch references. const TS_ARGUMENT_NOT_ASSIGNABLE = 2345; // "Argument of type X is not assignable to parameter of type Y." +const TS_TYPE_NOT_ASSIGNABLE = 2322; // "Type X is not assignable to type Y." (nested positions: object fields, array elements) const TS_PROPERTY_DOES_NOT_EXIST = 2339; // "Property 'p' does not exist on type X." /** @@ -43,11 +44,18 @@ const isSoftReferenceMismatch = ( const node = findInnermostNode(sourceFile, diagnostic.start, diagnostic.start + diagnostic.length); if (!node) return false; - // Argument not assignable: the argument's type is a union and at least one of its - // non-nullish branches is assignable to the contextually expected parameter type. - // Nullish branches are excluded so that `NUMBER | null` (no assignable base branch) - // stays a hard error, while `TEXT | null` and `TEXT | { deep: TEXT }` soften. - if (diagnostic.code === TS_ARGUMENT_NOT_ASSIGNABLE && ts.isExpression(node)) { + // (Argument | value) not assignable: the (argument | assigned value)'s type is a + // union and at least one of its non-nullish branches is assignable to the + // contextually expected type. Nullish branches are excluded so that `NUMBER | null` + // (no assignable base branch) stays a hard error, while `TEXT | null` and + // `TEXT | { deep: TEXT }` soften. Code 2345 covers a direct call argument; code 2322 + // covers the same mismatch in a nested position — an object-literal field or an + // array-literal element — where a `TEXT | BOOLEAN` reference is dropped into a plain + // TEXT slot. Both are references the schema engine offers as suggestions. + if ( + (diagnostic.code === TS_ARGUMENT_NOT_ASSIGNABLE || diagnostic.code === TS_TYPE_NOT_ASSIGNABLE) && + ts.isExpression(node) + ) { const argType = checker.getTypeAtLocation(node); if (!argType.isUnion()) return false; diff --git a/test/flowValidation.test.ts b/test/flowValidation.test.ts index aaa3483..22b1e65 100644 --- a/test/flowValidation.test.ts +++ b/test/flowValidation.test.ts @@ -1885,4 +1885,280 @@ describe('getFlowValidation - Integrationstest', () => { }); }); + describe('multi-type (non-nullable) union references into a plain parameter', () => { + + // custom::text_or_bool(): TEXT | BOOLEAN — a reference whose value can be two + // (or more) genuinely different types at the same time. Neither branch is + // nullish; one branch (TEXT) satisfies a plain TEXT parameter, the other + // (BOOLEAN) does not. + const TEXT_OR_BOOL_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9300", + identifier: "custom::text_or_bool", + signature: "(): TEXT | BOOLEAN", + }; + + // custom::text_or_number_or_bool(): TEXT | NUMBER | BOOLEAN — three simultaneous + // types, only one of which (TEXT) fits a plain TEXT parameter. + const TEXT_NUMBER_OR_BOOL_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9301", + identifier: "custom::text_or_number_or_bool", + signature: "(): TEXT | NUMBER | BOOLEAN", + }; + + // custom::number_or_bool(): NUMBER | BOOLEAN — no branch fits a TEXT parameter, + // so this must stay a hard error. + const NUMBER_OR_BOOL_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9302", + identifier: "custom::number_or_bool", + signature: "(): NUMBER | BOOLEAN", + }; + + const CUSTOM_FUNCTIONS = [ + ...FUNCTION_SIGNATURES, + TEXT_OR_BOOL_FN, + TEXT_NUMBER_OR_BOOL_FN, + NUMBER_OR_BOOL_FN, + ]; + + // Builds a two-node flow: node 1 runs `firstIdentifier` (no parameters), + // node 2 runs std::text::split(value: TEXT, delimiter: TEXT) with `valueRef` + // as its `value` argument. + const buildFlow = (firstIdentifier: string, valueRef: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: firstIdentifier}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: []}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::text::split"}, + parameters: { + nodes: [ + {value: valueRef}, + {value: {__typename: "LiteralValue", value: ","}}, + ], + }, + }, + ], + }, + }); + + it('warns (but stays valid) for a TEXT | BOOLEAN reference into a plain TEXT parameter', () => { + // The reference is `string | boolean`; the parameter wants `string`. TypeScript + // reports "Argument of type 'string | boolean' is not assignable to parameter of + // type 'string'." Because the TEXT branch fits, using it is a warning — the value + // might be a boolean at runtime, but the flow itself stays valid. + const flow = buildFlow("custom::text_or_bool", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(true); + expect(result.diagnostics.every(d => d.severity !== "error")).toBe(true); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "warning", + }), + ])); + }); + + it('warns (but stays valid) for a TEXT | NUMBER | BOOLEAN reference into a plain TEXT parameter', () => { + // Three simultaneous types, only TEXT fits — still a warning, not an error. + const flow = buildFlow("custom::text_or_number_or_bool", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(true); + expect(result.diagnostics.every(d => d.severity !== "error")).toBe(true); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "warning", + }), + ])); + }); + + it('still rejects a NUMBER | BOOLEAN reference for a plain TEXT parameter', () => { + // No branch of the union satisfies TEXT, so this stays a hard error. + const flow = buildFlow("custom::number_or_bool", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ])); + }); + + }); + + describe('multi-type union references in nested positions (object fields / array elements)', () => { + + // custom::src_union(): {u: TEXT | BOOLEAN} — a source whose `u` property is a + // reference that can be two genuinely different types at the same time. Reached + // as `node1.u` via an inline reference. + const SRC_UNION: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9400", + identifier: "custom::src_union", + signature: "(): {u: TEXT | BOOLEAN}", + }; + // Sinks that place the reference in a nested position: inside an object literal + // field and inside an array literal element. In these positions TypeScript + // reports the assignment as code 2322 ("Type 'string | boolean' is not + // assignable to type 'string'.") rather than the argument-level code 2345. + const SINK_OBJ: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9401", + identifier: "custom::sink::obj_nested", + signature: "(value: {count: NUMBER, label: TEXT}): void", + }; + const SINK_TEXT_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9402", + identifier: "custom::sink::textList", + signature: "(value: LIST): void", + }; + // Sinks whose nested slot is a NUMBER, which no branch of TEXT | BOOLEAN fits — + // these must stay hard errors. + const SINK_OBJ_NUMBER: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9403", + identifier: "custom::sink::obj_nested_number", + signature: "(value: {count: NUMBER}): void", + }; + const SINK_NUMBER_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9404", + identifier: "custom::sink::numberList2", + signature: "(value: LIST): void", + }; + + const CUSTOM_FUNCTIONS = [ + ...FUNCTION_SIGNATURES, + SRC_UNION, SINK_OBJ, SINK_TEXT_LIST, SINK_OBJ_NUMBER, SINK_NUMBER_LIST, + ]; + + const NODE1 = "gid://sagittarius/NodeFunction/1"; + + // An inline reference to `custom::src_union`'s `u` property (TEXT | BOOLEAN). + // A LiteralValue field equal to exactly `${u}` becomes the bare reference + // expression (not a coerced template string), placing the union in the slot. + const inlineU: any = { + __typename: "InlineReferenceValue", + signature: "u", + value: { + __typename: "ReferenceValue", + nodeFunctionId: NODE1, + referencePath: [{path: "u"}], + }, + }; + + // node1 = custom::src_union, node2 = `sink` consuming a single literal argument + // that carries the inline reference. + const buildFlow = (sink: string, literal: unknown): Flow => ({ + startingNodeId: NODE1, + signature: "(): void", + nodes: { + nodes: [ + { + id: NODE1, + functionDefinition: {identifier: "custom::src_union"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: []}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: sink}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: literal, references: [inlineU]}}, + ], + }, + }, + ], + }, + } as Flow); + + it('warns (but stays valid) for a TEXT | BOOLEAN reference in an object field expecting TEXT', () => { + const flow = buildFlow("custom::sink::obj_nested", {count: 1, label: "${u}"}); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(true); + expect(result.diagnostics.every(d => d.severity !== "error")).toBe(true); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "warning", + }), + ])); + }); + + it('warns (but stays valid) for a TEXT | BOOLEAN reference in an array element expecting TEXT', () => { + const flow = buildFlow("custom::sink::textList", ["${u}"]); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(true); + expect(result.diagnostics.every(d => d.severity !== "error")).toBe(true); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "warning", + }), + ])); + }); + + it('still rejects a TEXT | BOOLEAN reference in an object field expecting NUMBER', () => { + // Neither TEXT nor BOOLEAN fits NUMBER, so the nested mismatch stays an error. + const flow = buildFlow("custom::sink::obj_nested_number", {count: "${u}"}); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ])); + }); + + it('still rejects a TEXT | BOOLEAN reference in an array element expecting NUMBER', () => { + const flow = buildFlow("custom::sink::numberList2", ["${u}"]); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ])); + }); + + }); + }); \ No newline at end of file diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 0cc1d94..924beaa 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from "vitest"; import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; -import {getSignatureSchema, getTypeSchema, ListSubFlowInput, SubFlowInput} from "../../src"; +import {getSignatureSchema, getTypeSchema, ListSubFlowInput} from "../../src"; import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; describe("Schema", () => { @@ -1720,7 +1720,7 @@ describe("Schema", () => { } as FunctionDefinition; const functions = [...FUNCTION_SIGNATURES, pickMethods]; - it("surfaces list-select in a signature schema with only valid reference suggestions", () => { + it("surfaces list-select in a signature schema offering the in-scope list reference", () => { // node1 returns LIST; node2's first parameter references it. const flow: Flow = { id: "gid://sagittarius/Flow/1", @@ -1761,22 +1761,29 @@ describe("Schema", () => { ); expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual(methodItemsWithSuggestions); - - // The only suggestion is the in-scope reference to node1, whose - // return type (LIST) matches the parameter. No stray - // single-method / cross-type suggestions leak in. - expect(first.schema.suggestions).toEqual([ - { - __typename: "ReferenceValue", - nodeFunctionId: "gid://sagittarius/NodeFunction/1", - }, - ]); + const items = (first.schema as any).items; + expect(items).toHaveLength(methodItems.length); + items.forEach((item: any) => expect(item.input).toBe("select")); + + // The in-scope reference to node1 (return type LIST) is + // offered for the whole list. Compatible function nodes may also be + // suggested alongside it. + expect(first.schema.suggestions).toEqual( + expect.arrayContaining([ + { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }, + ]), + ); }); - it("keeps the full items and stays a list-select when a value is provided", () => { - // A provided literal array must not collapse the options to just the - // supplied values — the function-declared type stays the source of truth. + it("renders one item per provided value, each a select carrying the full options", () => { + // A provided literal array is value-driven: `items` has exactly one + // entry per entered value (like an object's properties). Each item + // keeps the declared element kind (select) and carries the full set + // of options as suggestions; its `type` is the concrete value's base + // type (string). The list kind still comes from the declared type. const flow: Flow = { id: "gid://sagittarius/Flow/1", startingNodeId: "gid://sagittarius/NodeFunction/1", @@ -1804,9 +1811,21 @@ describe("Schema", () => { "gid://sagittarius/NodeFunction/1", ); + const methodOptions = methodItems.map((item) => ({ + __typename: "LiteralValue", + value: JSON.parse(item.type), + })); + expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual(methodItemsWithSuggestions); - expect(first.schema.suggestions).toBeUndefined(); + const items = (first.schema as any).items; + expect(items).toHaveLength(2); + items.forEach((item: any) => { + expect(item.input).toBe("select"); + expect(item.type).toBe("string"); + // Each item carries the full method options; compatible function + // nodes may be suggested alongside them. + expect(item.suggestions).toEqual(expect.arrayContaining(methodOptions)); + }); }); }); @@ -2050,4 +2069,388 @@ describe("Schema", () => { }); }); + describe("generic slot suggestion stability", () => { + // A position the declared type leaves unconstrained (an element of + // `LIST`, a property of `OBJECT`, and everything nested under them) + // accepts anything. So the concrete value entered there drives the *shape* + // (number/text/boolean, how many items/keys) but must never change the + // *suggestions*: every such position offers the same constant "accepts + // anything" set, no matter whether — or what kind of — value is present. + + // Navigate a schema by a path of property names and numeric item indices. + const at = (schema: any, path: (string | number)[]): any => + path.reduce((s, step) => { + if (typeof step === "number") return s.items[step]; + const prop = s.properties[step]; + return Array.isArray(prop) ? prop[0] : prop; + }, schema); + + const sortedSuggestions = (schema: any): string[] => + ((schema.suggestions ?? []) as any[]).map((s) => JSON.stringify(s)).sort(); + + // std::object::get(object: OBJECT, key: K): T[K] + // → parameter 0 is the fully generic OBJECT. + const objectGetFlow = (objectValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::object::get"}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: objectValue}}, + {value: null}, + ], + }, + }, + ], + }, + }); + + // std::list::filter(list: LIST, predicate: PREDICATE): LIST + // → parameter 0 is the fully generic LIST. + const listFilterFlow = (listValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::list::filter"}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: listValue}}, + {value: null}, + ], + }, + }, + ], + }, + }); + + const firstParam = (flow: Flow): any => + getSignatureSchema( + flow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ).parameters[0].schema; + + it("gives an OBJECT property leaf the same suggestions for any value kind", () => { + const forValue = (v: any) => + sortedSuggestions(at(firstParam(objectGetFlow({k: v})), ["k"])); + + const numberSet = forValue(1); + const stringSet = forValue("x"); + const booleanSet = forValue(true); + + expect(numberSet.length).toBeGreaterThan(0); + expect(stringSet).toEqual(numberSet); + expect(booleanSet).toEqual(numberSet); + }); + + it("drives an OBJECT property leaf's shape by the value while keeping suggestions constant", () => { + const numberLeaf = at(firstParam(objectGetFlow({k: 1})), ["k"]); + const stringLeaf = at(firstParam(objectGetFlow({k: "x"})), ["k"]); + + // Shape follows the value... + expect(numberLeaf.input).toBe("number"); + expect(stringLeaf.input).toBe("text"); + + // ...but the suggestions do not. + expect(sortedSuggestions(stringLeaf)).toEqual(sortedSuggestions(numberLeaf)); + }); + + it("gives a LIST item the same suggestions for any value kind", () => { + const forValue = (v: any) => + sortedSuggestions(at(firstParam(listFilterFlow([v])), [0])); + + const numberSet = forValue(1); + const stringSet = forValue("x"); + const booleanSet = forValue(true); + + expect(numberSet.length).toBeGreaterThan(0); + expect(stringSet).toEqual(numberSet); + expect(booleanSet).toEqual(numberSet); + }); + + it("keeps a deeply nested leaf (object → list → object → primitive) value-independent", () => { + // Everything below the generic OBJECT is unconstrained, so the leaf + // `a[0].b` offers the same set whether it holds a number or a string. + const numberLeaf = at(firstParam(objectGetFlow({a: [{b: 1}]})), ["a", 0, "b"]); + const stringLeaf = at(firstParam(objectGetFlow({a: [{b: "s"}]})), ["a", 0, "b"]); + + expect(numberLeaf.input).toBe("number"); + expect(stringLeaf.input).toBe("text"); + + const numberSet = sortedSuggestions(numberLeaf); + expect(numberSet.length).toBeGreaterThan(0); + expect(sortedSuggestions(stringLeaf)).toEqual(numberSet); + }); + + it("offers the full generic set at a leaf — broader than the enclosing container's own suggestions", () => { + // The OBJECT slot itself only accepts object-producing candidates, + // but a leaf under it accepts anything, so the leaf's set is a strict + // superset of the container's — and both stay stable across values. + const container = firstParam(objectGetFlow({k: 1})); + const leaf = at(container, ["k"]); + + const containerSet = new Set(sortedSuggestions(container)); + const leafSet = new Set(sortedSuggestions(leaf)); + + expect(containerSet.size).toBeGreaterThan(0); + expect(leafSet.size).toBeGreaterThan(containerSet.size); + for (const s of containerSet) expect(leafSet.has(s)).toBe(true); + }); + + it("renders one list item per entered element for a list nested in an OBJECT value", () => { + // "Get key of object" (std::object::get) with a literal object value + // {test: [1, 1, 1]}. The `object` slot is a generic OBJECT, so its + // shape is driven by the value: the `test` property holds a list of + // three entered elements and must surface exactly three list items — + // just like a top-level array-literal argument does. TypeScript + // collapses [1, 1, 1] to number[] (a single element type), so a + // type-driven schema would produce only one item; the value cardinality + // must be preserved instead. + const schema = firstParam(objectGetFlow({test: [1, 1, 1]})); + + const list = at(schema, ["test"]); + expect((list.input as string).startsWith("list")).toBe(true); + expect(list.items).toHaveLength(3); + }); + + it("keeps a constrained generic parameter (key: K extends keyof T) scoped, not widened to `any`", () => { + // Regression guard. object::get's second parameter is `K extends + // keyof T`. Its function schema resolves to `generic` (keyof T with a + // free T), but — unlike a nested leaf — its suggestions were already + // scoped to the constraint at the parameter root. It must NOT be + // widened to the unconstrained `any` set: a BOOLEAN-returning function + // is not a valid key. + const identifiers = (schema: any) => + new Set( + ((schema.suggestions ?? []) as any[]).map( + (s) => s.functionDefinition?.identifier ?? s.__typename, + ), + ); + + const keyParam = getSignatureSchema( + objectGetFlow({test2: null, test3: 1}), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ).parameters[1].schema; + const keySet = identifiers(keyParam); + + // std::boolean::negate returns BOOLEAN → not assignable to keyof T → + // must be absent from the key slot... + expect(keySet.has("std::boolean::negate")).toBe(false); + + // ...but present in the unconstrained set a nested generic leaf gets. + const leafSet = identifiers(at(firstParam(objectGetFlow({k: 1})), ["k"])); + expect(leafSet.has("std::boolean::negate")).toBe(true); + + // The constrained key slot stays strictly narrower than the leaf. + expect(keySet.size).toBeLessThan(leafSet.size); + }); + + it("keeps a concrete deeply-nested typed OBJECT scoped per field, not flattened to `any`", () => { + // The complement of the generic case: when the declared type IS a + // concrete nested object, every field is present on both the function + // and node side, so the pairwise merge keeps each level scoped to its + // declared type. `genericNodeSchema`/`any` must never reach in here. + const applyConfig = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/901", + identifier: "test::config::apply", + signature: + "(config: OBJECT<{ meta: OBJECT<{ name: TEXT }>, method: HTTP_METHOD }>): void", + } as FunctionDefinition; + const functions = [...FUNCTION_SIGNATURES, applyConfig]; + + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "test::config::apply"}, + parameters: { + nodes: [ + { + value: { + __typename: "LiteralValue", + value: {meta: {name: "x"}, method: "GET"}, + }, + }, + ], + }, + }, + ], + }, + }; + + const config = getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/1", + ).parameters[0].schema; + + // Nested TEXT field → stays a text input scoped to TEXT candidates. + const name = at(config, ["meta", "name"]); + expect(name.input).toBe("text"); + const nameIds = new Set( + ((name.suggestions ?? []) as any[]).map( + (s) => s.functionDefinition?.identifier ?? s.__typename, + ), + ); + // A BOOLEAN-returning function is not assignable to TEXT → excluded, + // proving the field is NOT the unconstrained `any` set. + expect(nameIds.has("std::boolean::negate")).toBe(false); + + // Nested HTTP_METHOD field → keeps its select shape. + const method = at(config, ["method"]); + expect(method.input).toBe("select"); + }); + + it("gives an extra field, absent from the concrete declared OBJECT, a generic input", () => { + // The declared type constrains only `test: TEXT`. The value carries an + // additional `test2` field the declared object does not mention. That + // field lives in an unconstrained slot, so it must surface as the + // generic input — the declared type says nothing about it. + const storeConfig = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/902", + identifier: "test::object::store", + signature: "(object: OBJECT<{ test: TEXT }>): void", + } as FunctionDefinition; + const functions = [...FUNCTION_SIGNATURES, storeConfig]; + + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "test::object::store"}, + parameters: { + nodes: [ + { + value: { + __typename: "LiteralValue", + value: {test: "test", test2: null}, + }, + }, + ], + }, + }, + ], + }, + }; + + const object = getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/1", + ).parameters[0].schema; + + // Declared field → stays scoped to its declared TEXT kind. + expect(at(object, ["test"]).input).toBe("text"); + + // Undeclared field → unconstrained → generic input. + expect(at(object, ["test2"]).input).toBe("generic"); + }); + }); + + // An *optional* object parameter (`embed?: OBJ`, i.e. `OBJ | undefined`) must + // expose the same nested-field suggestions as the same parameter declared + // *required* (`embed: OBJ`). Only the optionality differs; the resolved type of + // each nested field (e.g. `title: TEXT`) is `string` either way, so the nested + // suggestions must not disappear just because the top-level slot is nullable. + describe("optional object parameter keeps nested suggestions", () => { + + // A generic two-field object: one required TEXT field, one optional TEXT + // field. Deliberately not a product-specific data type. + const DEMO_OBJECT: DataType = { + identifier: "DEMO_OBJECT", + genericKeys: [], + type: "{ title: TEXT; description?: TEXT }", + } as DataType; + + // Two functions that differ *only* in the optionality of the object parameter. + const withOptional: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9500", + identifier: "custom::test::with_optional_object", + signature: "(embed?: DEMO_OBJECT): void", + } as FunctionDefinition; + + const withRequired: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9501", + identifier: "custom::test::with_required_object", + signature: "(embed: DEMO_OBJECT): void", + } as FunctionDefinition; + + // A single node calling `identifier` with no value supplied for the object + // parameter, probed at that node. Returns the schema for the sole parameter. + const probe = (identifier: string) => { + 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: null}]}, + }, + ], + }, + }; + return getSignatureSchema( + flow, + [...DATA_TYPES, DEMO_OBJECT], + [...FUNCTION_SIGNATURES, withOptional, withRequired], + "gid://sagittarius/NodeFunction/1", + ).parameters[0].schema as any; + }; + + const titleSuggestionCount = (paramSchema: any): number => + (paramSchema?.properties?.title?.suggestions ?? []).length; + + it("exposes suggestions on the nested `title` field when required", () => { + const required = probe("custom::test::with_required_object"); + + expect(required.input).toBe("data"); + // Baseline: the required case works — nested `title` gets suggestions + // (functions returning TEXT are valid producers for that slot). + expect(titleSuggestionCount(required)).toBeGreaterThan(0); + }); + + it("exposes the SAME nested suggestions when optional as when required", () => { + const optional = probe("custom::test::with_optional_object"); + const required = probe("custom::test::with_required_object"); + + // The optional slot still resolves to the object shape... + expect(optional.input).toBe("data"); + expect(Object.keys(optional.properties)).toEqual( + Object.keys(required.properties), + ); + + // ...and its nested fields must carry the same suggestions. Optionality + // of the parent must not strip suggestions from the children. + expect(titleSuggestionCount(optional)).toBe(titleSuggestionCount(required)); + expect(titleSuggestionCount(optional)).toBeGreaterThan(0); + }); + }); + }) \ No newline at end of file