diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index a498239..9083b69 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,7 +422,45 @@ const generateNodeSchemas = ( ? widenForSuggestions(checker, functionParameterType, node!) : undefined - const nodeSchema = getSchema( + // 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. + const argExpr = getArgumentExpression(node, index) + if (argExpr && ts.isArrayLiteralExpression(argExpr)) { + const listSuggestions = getSchema( + checker, + node, + parameterType, + Array.from(declaredFunctionsMap.values()), + functions, + true, + suggestionType, + ).suggestions + return { + schema: buildValueDrivenListSchema( + checker, + node, + functionParameterType, + argExpr, + Array.from(declaredFunctionsMap.values()), + functions, + listSuggestions, + 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, @@ -417,7 +468,7 @@ const generateNodeSchemas = ( functions, true, suggestionType, - ) + )) const functionSchema = functionParameterType ? getSchema( checker, @@ -434,6 +485,7 @@ const generateNodeSchemas = ( functionSchema, nodeSchema, valueProvidedByIndex[index] ?? false, + anySuggestions, ), blockedBy: funktionDependencies .filter((dep) => dep.parameterIndex === index) @@ -442,6 +494,122 @@ 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) + } + + 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! +} + // 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..a0775bd 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -592,17 +592,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 +740,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 +755,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 +801,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..c970afb 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,239 @@ 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("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"); + }); + }); + }) \ No newline at end of file