diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index 1df0ccc..bcd0ac3 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, Schema} from "../util/schema.util" +import {getSchema, mergeSchemas, Schema} from "../util/schema.util" /** * Represents the schema information for a node parameter. @@ -9,10 +9,14 @@ import {getSchema, Schema} from "../util/schema.util" */ export interface NodeSchema { nodeId: NodeFunction["id"] - /** The schema definition for this node parameter */ + /** + * The schema definition for this node parameter. Produced by merging the + * function-declared parameter schema with the node's concrete value schema: + * the function schema drives the structural shape, the node schema contributes + * additional suggestions, and a generic function parameter falls back to the + * node's concrete shape (never as a select). + */ schema: Schema - /** The schema definition for the function parameter */ - functionSchema: Schema /** Array of parameter indices that must be resolved before this parameter */ blockedBy?: number[] } @@ -95,6 +99,14 @@ export const getSignatureSchema = ( // Identify parameter dependencies based on type parameters const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes) + // Track which parameter slots actually carry a user-supplied value. The merge + // uses this as a last-resort signal: if the function- and node-side schemas + // both came out generic but the user did set something, the lift falls back + // to `data` so the UI has an open object to render against. + const valueProvidedByIndex = (targetNode?.parameters?.nodes ?? []).map( + (p) => p?.value != null + ) + // Generate schema for each parameter return generateNodeSchemas( nodeId, @@ -105,6 +117,7 @@ export const getSignatureSchema = ( funktionDependencies, nodeId ? declaredFunctionsMap : new Map(), nodeId ? functions : [], + valueProvidedByIndex, ) } @@ -292,9 +305,11 @@ const getParameterDependencies = ( * @param checker - The TypeScript type checker * @param node - The node's variable declaration * @param nodeParameterTypes - Merged parameter types to use for schema generation + * @param functionParameterTypes * @param funktionDependencies - Parameter dependencies to link with each parameter * @param declaredFunctionsMap - Map of available functions for schema context * @param functions - Array of function definitions + * @param valueProvidedByIndex * @returns Array of NodeSchema objects */ const generateNodeSchemas = ( @@ -306,31 +321,67 @@ const generateNodeSchemas = ( funktionDependencies: ParameterDependency[], declaredFunctionsMap: Map, functions: FunctionDefinition[], + valueProvidedByIndex: boolean[], ): NodeSchema[] => { if (!nodeParameterTypes) { return [] } - return nodeParameterTypes.map((parameterType, index) => ({ - nodeId: nodeId, - schema: getSchema( + 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 + // setting a boolean literal in a generic slot would silently hide all + // other suggestions. + const suggestionType = functionParameterType + ? widenForSuggestions(checker, functionParameterType) + : undefined + + const nodeSchema = getSchema( checker, node, parameterType, Array.from(declaredFunctionsMap.values()), - functions - ), - functionSchema: getSchema( - checker, - node, - functionParameterTypes?.[index]!, - Array.from(declaredFunctionsMap.values()), functions, - false - ), - blockedBy: funktionDependencies - .filter((dep) => dep.parameterIndex === index) - .map((dep) => dep.dependsOnIndex), - })) + true, + suggestionType, + ) + const functionSchema = functionParameterType + ? getSchema( + checker, + node, + functionParameterType, + Array.from(declaredFunctionsMap.values()), + functions, + false + ) + : undefined + + return { + nodeId: nodeId, + schema: mergeSchemas( + functionSchema, + nodeSchema, + valueProvidedByIndex[index] ?? false, + ), + blockedBy: funktionDependencies + .filter((dep) => dep.parameterIndex === index) + .map((dep) => dep.dependsOnIndex), + } + }) +} + +// 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 +// parameter is replaced by its constraint. Everything else is used as-is. +const widenForSuggestions = (checker: ts.TypeChecker, type: ts.Type): ts.Type => { + if ((type.flags & ts.TypeFlags.TypeParameter) === 0) return type + const decl = type.symbol?.declarations?.[0] + if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) { + return checker.getTypeFromTypeNode(decl.constraint) + } + return checker.getAnyType() } diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index e68aeb1..b474825 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -124,7 +124,8 @@ export const getSchema = ( parameterType: ts.Type, functionDeclarations: FunctionDeclaration[], functions: FunctionDefinition[], - suggestions: boolean = true + suggestions: boolean = true, + suggestionType?: ts.Type, ): Schema => { if ((parameterType.flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -132,31 +133,37 @@ export const getSchema = ( if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) { // getTypeFromTypeNode statt getBaseConstraintOfType → aliasSymbol bleibt erhalten const constraintType = checker.getTypeFromTypeNode(decl.constraint) - return getSchema(checker, node, constraintType, functionDeclarations, functions, suggestions) + return getSchema(checker, node, constraintType, functionDeclarations, functions, suggestions, suggestionType) } } + // Suggestions are filtered by what the surrounding function accepts, not by + // the narrower type a current value happens to narrow the node-side to. + // Example: `(value: T)` with a current boolean literal must still surface + // every reference in scope, because the function takes anything. + const typeForSuggestions = suggestionType ?? parameterType; + // Collect all available suggestions for this parameter const combinedSuggestions = suggestions ? { suggestions: [ - ...getValues(parameterType, checker), + ...getValues(typeForSuggestions, checker), ...(node ? getReferences( checker, node, - parameterType, + typeForSuggestions, checker.getSymbolsInScope(node, ts.SymbolFlags.Variable) ) : []), ...getNodes( checker, functionDeclarations, functions, - parameterType + typeForSuggestions ), ...getSubFlows( checker, functionDeclarations, functions, - parameterType + typeForSuggestions ), ], } : {}; @@ -274,9 +281,161 @@ export const getSchema = ( }; } - // Fallback for unknown or generic types + // Fallback for unknown or generic types — still surface any collected + // suggestions (e.g. references in scope) so the UI is never silently empty. return { input: "generic", + ...combinedSuggestions, + }; +}; + +/** + * Merges a function-declared parameter schema with the schema derived from the + * concrete node value. The function schema is treated as the source of truth for + * the structural shape (input kind, properties, items); the node schema only + * contributes additional suggestions and, when the function schema is generic, + * a fallback shape. + * + * Rules: + * - If the function schema is generic, follow the node schema — but never as a + * select. A single literal value (e.g. "Test") narrowing a generic T must not + * collapse the input into a select with one option; it should remain free-form + * text/number/boolean matching the literal kind. + * - Otherwise use the function schema's input kind and merge suggestions from both. + * Recurse into `properties` (for data) and `items` (for list) so nested generics + * inside concrete containers are handled the same way. + * + * Suggestions are concatenated and de-duplicated by structural equality. + * + * @param functionSchema - The schema derived from the declared function parameter type + * @param nodeSchema - The schema derived from the node's concrete (narrowed) parameter type + * @returns A single merged schema + */ +export const mergeSchemas = ( + functionSchema: Schema | undefined, + nodeSchema: Schema, + valueProvided: boolean = false, +): Schema => { + if (!functionSchema) { + return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided); + } + + if (functionSchema.input === "generic") { + return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided); + } + + const suggestions = mergeSuggestions( + functionSchema.suggestions, + nodeSchema.suggestions, + ); + + if (functionSchema.input === "data") { + const fProps = functionSchema.properties ?? {}; + const nProps = + nodeSchema.input === "data" ? (nodeSchema.properties ?? {}) : {}; + 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); + } + return { + ...functionSchema, + properties, + ...(suggestions ? {suggestions} : {}), + }; + } + + if (functionSchema.input === "list") { + const fItems = functionSchema.items ?? []; + const nItems = + nodeSchema.input === "list" ? (nodeSchema.items ?? []) : []; + const items = + fItems.length === nItems.length && fItems.length > 0 + ? fItems.map((f, i) => mergeSchemas(f, nItems[i])) + : fItems; + return { + ...functionSchema, + items, + ...(suggestions ? {suggestions} : {}), + }; + } + + return { + ...functionSchema, + ...(suggestions ? {suggestions} : {}), + }; +}; + +const mergeProperty = ( + f: Schema | Schema[] | undefined, + n: Schema | Schema[] | undefined, +): Schema | Schema[] => { + if (f && !Array.isArray(f) && n && !Array.isArray(n)) { + return mergeSchemas(f, n); + } + return (f ?? n)!; +}; + +const mergeSuggestions = ( + a: Input["suggestions"], + b: Input["suggestions"], +): Input["suggestions"] | undefined => { + const all = [...(a ?? []), ...(b ?? [])]; + if (all.length === 0) return undefined; + const seen = new Set(); + const result: NonNullable = []; + for (const item of all) { + const key = JSON.stringify(item); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; +}; + +// Generic means "we extracted nothing structural from either side". That is the +// right answer for an empty parameter slot, but if the user has provided a value +// then the merge already had the node-side schema to draw from — if both sides +// still came out generic (e.g. the value resolved to `any`/`unknown`), the most +// useful open shape is `data`. Primitive values never reach this branch: their +// node schema is text / number / boolean / select-then-demoted, so the merge +// produces a concrete kind before this lift runs. +const liftGenericIfValued = (schema: Schema, valueProvided: boolean): Schema => { + if (!valueProvided || schema.input !== "generic") return schema; + return { + input: "data", + properties: {}, + required: [], + ...(schema.suggestions ? {suggestions: schema.suggestions} : {}), + }; +}; + +const demoteSelect = (schema: Schema): Schema => { + if (schema.input !== "select") return schema; + const suggestions = schema.suggestions ?? []; + const literalKinds = new Set(); + for (const s of suggestions) { + const value = (s as LiteralValue).value; + const kind = typeof value; + if (kind === "string" || kind === "number" || kind === "boolean") { + literalKinds.add(kind); + } + } + const target: PrimitiveInput["input"] = + literalKinds.size === 1 + ? ( + { + string: "text", + number: "number", + boolean: "boolean", + } as const + )[[...literalKinds][0] as "string" | "number" | "boolean"] + : "text"; + return { + input: target, + ...(suggestions.length > 0 ? {suggestions} : {}), }; }; diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 024416c..d69a838 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1,4 +1,4 @@ -import {describe, it} from "vitest"; +import {describe, expect, it} from "vitest"; import {Flow} from "@code0-tech/sagittarius-graphql-types"; import {getSignatureSchema, getTypeSchema} from "../../src"; import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; @@ -276,4 +276,285 @@ describe("Schema", () => { //console.dir(result, {depth: null}) }); + it('merges function-typed select suggestions with concrete node value', () => { + // http_method has function-declared type HTTP_METHOD ('GET' | 'POST' | ...). + // Node value "GET" must not collapse the select suggestions to just ['GET']. + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "http::request::send"}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: "GET"}}, + {value: {__typename: "LiteralValue", value: "/x"}}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + ], + }, + }, + ], + }, + }; + + const [first] = getSignatureSchema( + flow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + + expect(first.schema.input).toBe("select"); + const values = (first.schema.suggestions ?? []).map((s: any) => s.value); + expect(values).toEqual( + expect.arrayContaining(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]), + ); + }); + + it('return: ReferenceValue (payload) vs LiteralValue feeding a generic T parameter', () => { + // Two-node flow: + // node 1: http::request::send → returns HTTP_RESPONSE ({ payload, headers, http_status_code }). + // node 2: std::control::return(value: T) + // We probe node 2's parameter schema once with a ReferenceValue that pulls + // the .payload of node 1, and once with a LiteralValue. Both feed the same + // generic T parameter, so the schema's structural shape is driven by the + // node side (function side is generic). + const buildFlow = (returnValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "http::request::send"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: "GET"}}, + {value: {__typename: "LiteralValue", value: "/x"}}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + ], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::control::return"}, + parameters: { + nodes: [{value: returnValue}], + }, + }, + ], + }, + }); + + const refFlow = buildFlow({ + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + referencePath: [{path: "payload"}], + }); + const literalFlow = buildFlow({ + __typename: "LiteralValue", + value: "hello", + }); + + const [refResult] = getSignatureSchema( + refFlow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/2", + ); + const [literalResult] = getSignatureSchema( + literalFlow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/2", + ); + + // Function side is generic for both. The node side decides the structural shape. + + // ReferenceValue → node value resolves to HTTP_RESPONSE.payload = any. + // `any` carries no structural info but its runtime shape is open — surface it + // as a `data` schema (open object) rather than the dead-end `generic`. + expect(refResult.schema.input).toBe("data"); + + // Every reference in scope (node 1 with each of its response paths, plus + // top-level node/flow references and zero-arg node-function nodes) is + // assignable to `any`, so all of them must be offered as suggestions. + const refSuggestions = (refResult.schema.suggestions ?? []) as any[]; + expect(refSuggestions.length).toBeGreaterThan(0); + expect( + refSuggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + Array.isArray(s.referencePath) && + s.referencePath.some((p: any) => p.path === "payload"), + ), + ).toBe(true); + expect( + refSuggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + Array.isArray(s.referencePath) && + s.referencePath.some((p: any) => p.path === "headers"), + ), + ).toBe(true); + expect( + refSuggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + Array.isArray(s.referencePath) && + s.referencePath.some((p: any) => p.path === "http_status_code"), + ), + ).toBe(true); + + // LiteralValue "hello" → narrows T to the string literal "hello". The node + // schema alone would be a select with one option; the merge must demote it + // to free-form text because the function side is generic. + expect(literalResult.schema.input).toBe("text"); + + const literalSuggestions = (literalResult.schema.suggestions ?? []) as any[]; + + // Suggestions are scoped by the *function* side (T → any), not by the narrow + // "hello" literal. Every response field of node 1 is reachable, not just the + // payload that happens to be `any`-typed. + for (const expectedPath of ["payload", "headers", "http_status_code"]) { + expect( + literalSuggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + Array.isArray(s.referencePath) && + s.referencePath.some((p: any) => p.path === expectedPath), + ), + ).toBe(true); + } + }); + + it('generic function parameter gives the same suggestion set regardless of the current literal kind', () => { + // `std::control::return(value: T)` accepts anything. The set of + // suggestions the user can pick from should not shrink just because the + // currently-set value happens to narrow T (e.g. to `boolean`). + const buildFlow = (returnValue: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "http::request::send"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: "GET"}}, + {value: {__typename: "LiteralValue", value: "/x"}}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + {value: null}, + ], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::control::return"}, + parameters: { + nodes: [{value: returnValue}], + }, + }, + ], + }, + }); + + const probe = (returnValue: any) => { + const [r] = getSignatureSchema( + buildFlow(returnValue), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/2", + ); + return ((r.schema.suggestions ?? []) as any[]).map((s) => + JSON.stringify(s), + ); + }; + + // Snapshot the set of references/node-functions surfaced for each value + // kind. Strip literal-value suggestions because those legitimately differ + // (the empty-object case has no literal alternative; the boolean case + // would surface `true`/`false` via getValues of the booleans constraint). + const onlyRefsAndNodes = (sigs: string[]) => + sigs.filter((s) => !s.includes('"LiteralValue"')); + + const objectSet = onlyRefsAndNodes( + probe({__typename: "LiteralValue", value: {}}), + ); + const boolSet = onlyRefsAndNodes( + probe({__typename: "LiteralValue", value: true}), + ); + const stringSet = onlyRefsAndNodes( + probe({__typename: "LiteralValue", value: "x"}), + ); + const numberSet = onlyRefsAndNodes( + probe({__typename: "LiteralValue", value: 42}), + ); + + // All sets are non-empty (the function accepts anything → references in + // scope are valid candidates) and identical to each other. + expect(objectSet.length).toBeGreaterThan(0); + expect(new Set(boolSet)).toEqual(new Set(objectSet)); + expect(new Set(stringSet)).toEqual(new Set(objectSet)); + expect(new Set(numberSet)).toEqual(new Set(objectSet)); + }); + + it('demotes select to free-form when function parameter is generic', () => { + // std::control::return(value: T): T — function declares T, node sets a + // string literal. Result must be text (free-form), NOT select with one option. + const flow: Flow = { + id: "gid://sagittarius/Flow/2", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::control::return"}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: "Test"}}, + ], + }, + }, + ], + }, + }; + + const [first] = getSignatureSchema( + flow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + + expect(first.schema.input).toBe("text"); + }); + }) \ No newline at end of file