diff --git a/src/schema/getSignatureSchema.ts b/src/schema/getSignatureSchema.ts index 88fa711..5a081f9 100644 --- a/src/schema/getSignatureSchema.ts +++ b/src/schema/getSignatureSchema.ts @@ -15,6 +15,7 @@ import { Schema, withSuggestions, } from "../util/schema.util" +import {SuggestionCandidate, suggestionCandidates} from "../util/suggestion.util" /** * Represents the schema information for a node parameter. @@ -428,14 +429,14 @@ const generateNodeSchemas = ( // 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( + const anySuggestions = suggestionCandidates(getSchema( checker, node, checker.getAnyType(), Array.from(declaredFunctionsMap.values()), functions, true, - ).suggestions + )) return nodeParameterTypes.map((parameterType, index) => { const functionParameterType = functionParameterTypes?.[index] @@ -511,7 +512,7 @@ const generateNodeSchemas = ( (nodeTypeIsObject || (declaredMember != null && isPlainObjectType(checker, declaredMember))))) ) { - const wholeSuggestions = getSchema( + const wholeSuggestions = suggestionCandidates(getSchema( checker, node, parameterType, @@ -519,7 +520,7 @@ const generateNodeSchemas = ( functions, true, suggestionType, - ).suggestions + )) return { schema: ts.isArrayLiteralExpression(argExpr) ? buildValueDrivenListSchema( @@ -723,8 +724,8 @@ const buildValueDrivenListSchema = ( arrayExpr: ts.ArrayLiteralExpression, functionDeclarations: ts.FunctionDeclaration[], functions: FunctionDefinition[], - suggestions?: Schema["suggestions"], - anySuggestions?: Schema["suggestions"], + suggestions?: SuggestionCandidate[], + anySuggestions?: SuggestionCandidate[], ): Schema => { const funcSchema = funcListType ? getSchema(checker, node, funcListType, functionDeclarations, functions, true) @@ -778,11 +779,11 @@ const buildValueDrivenListSchema = ( */ const ownSuggestions = ( funcSchema: Schema | undefined, - anySuggestions?: Schema["suggestions"], -): Schema["suggestions"] | undefined => + anySuggestions?: SuggestionCandidate[], +): SuggestionCandidate[] | undefined => !funcSchema || funcSchema.input === "generic" ? anySuggestions - : funcSchema.suggestions + : suggestionCandidates(funcSchema) /** * Builds a single list item schema for one array-literal element. @@ -803,7 +804,7 @@ const buildValueDrivenItem = ( element: ts.Expression, functionDeclarations: ts.FunctionDeclaration[], functions: FunctionDefinition[], - anySuggestions?: Schema["suggestions"], + anySuggestions?: SuggestionCandidate[], ): Schema => { // A union declared element (or property) type lists the options of this // position; the entered value picks one, and that member is what describes @@ -852,7 +853,7 @@ const buildValueDrivenItem = ( ? {} : {type: checker.typeToString(valueType)}), } as Schema, - funcElementSchema!.suggestions, + suggestionCandidates(funcElementSchema!), ) } @@ -883,8 +884,8 @@ const buildValueDrivenObjectSchema = ( objectExpr: ts.ObjectLiteralExpression, functionDeclarations: ts.FunctionDeclaration[], functions: FunctionDefinition[], - suggestions?: Schema["suggestions"], - anySuggestions?: Schema["suggestions"], + suggestions?: SuggestionCandidate[], + anySuggestions?: SuggestionCandidate[], ): Schema => { const funcSchema = funcObjectType ? getSchema(checker, node, funcObjectType, functionDeclarations, functions, true) @@ -912,7 +913,7 @@ const buildValueDrivenObjectSchema = ( checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(objectExpr)), ), } as Schema, - suggestions ?? funcSchema.suggestions, + suggestions ?? suggestionCandidates(funcSchema), ) } diff --git a/src/util/nodes.util.ts b/src/util/nodes.util.ts index 13fe23e..d721984 100644 --- a/src/util/nodes.util.ts +++ b/src/util/nodes.util.ts @@ -1,5 +1,11 @@ import ts from "typescript"; import {FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types"; +import { + possibleMatch, + SuggestionCandidate, + typeMatchCertainty, + weakestCertainty, +} from "./suggestion.util"; /** * Filters and transforms function declarations into a collection of compatible node functions. @@ -25,9 +31,10 @@ import {FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql- * @param {ts.Type} paramType - The target parameter type used to filter compatible * functions. Only functions with return types assignable to this type are included * - * @returns {NodeFunction[]} Array of node functions that are compatible with the - * specified parameter type, or an empty array if no compatible - * functions are found + * @returns {SuggestionCandidate[]} Array of node-function candidates compatible with + * the specified parameter type, each carrying how certainly its return + * type produces that type, or an empty array if no compatible functions + * are found * * @example * const compatibleNodes = getNodes(checker, funcDecls, funcDefs, stringType); @@ -37,11 +44,11 @@ export const getNodes = ( functionDeclarations: ts.FunctionDeclaration[], functions: FunctionDefinition[], paramType: ts.Type -): NodeFunction[] => { +): SuggestionCandidate[] => { // Transform each function declaration into a node function if it matches the parameter type return functionDeclarations.flatMap((func) => { - const nodeFunction = createNodeFunctionIfCompatible(checker, func, functions, paramType); - return nodeFunction ? [nodeFunction] : []; + const candidate = createNodeFunctionIfCompatible(checker, func, functions, paramType); + return candidate ? [candidate] : []; }); }; @@ -53,13 +60,20 @@ export const getNodes = ( * It extracts the function signature, resolves type parameters, verifies type compatibility, * and builds a complete node function object with parameter definitions. * + * The match is not only a yes/no: a return type that had to be weakened to fit — + * a type parameter matched through its constraint, an `any` return, a return + * that may be nullish where the slot is not — yields a `possible` candidate + * rather than an `exact` one. The function is still offered, but a consumer can + * tell a dedicated producer of the slot's type from one that merely cannot be + * ruled out (see {@link SuggestionCertainty}). + * * @param {ts.TypeChecker} checker - The TypeScript type checker for type analysis * @param {ts.FunctionDeclaration} func - The function declaration to process * @param {FunctionDefinition[]} functions - Array of function definitions for metadata lookup * @param {ts.Type} paramType - The target parameter type for compatibility check * - * @returns {NodeFunction | null} A node function object if the function is compatible - * with the parameter type, otherwise null + * @returns {SuggestionCandidate | null} A node-function candidate with its certainty if + * the function is compatible with the parameter type, otherwise null * * @private */ @@ -68,7 +82,7 @@ const createNodeFunctionIfCompatible = ( func: ts.FunctionDeclaration, functions: FunctionDefinition[], paramType: ts.Type -): NodeFunction | null => { +): SuggestionCandidate | null => { // Extract the function signature and its return type const signature = checker.getSignatureFromDeclaration(func); @@ -77,25 +91,27 @@ const createNodeFunctionIfCompatible = ( // Simplify the return type by resolving type parameters const simplifiedReturnType = resolveReturnType(checker, returnType); - // Only proceed if the return type is assignable to the target parameter type. - // The nullish part of the return type is ignored: a function returning - // `string | null` is still a valid suggestion for a plain `string` parameter. - // A purely nullish return type strips down to `never` (assignable to anything), - // so it must be excluded explicitly. - const nonNullableReturnType = checker.getNonNullableType(simplifiedReturnType); - if ( - (nonNullableReturnType.flags & ts.TypeFlags.Never) !== 0 || - !checker.isTypeAssignableTo(nonNullableReturnType, paramType) - ) { + // Only proceed if the return type fits the target parameter type, under + // {@link typeMatchCertainty}'s rule: a function returning `string | null` is + // still offered for a plain `string` parameter, as a `nullable` possible + // match rather than being dropped. + const matched = typeMatchCertainty(simplifiedReturnType, checker, paramType); + if (!matched) { return null; } + // Resolving a type parameter to its constraint is a weakening of its own: the + // concrete instantiation may be narrower than what matched here. + const certainty = returnType.isTypeParameter() + ? weakestCertainty(matched, possibleMatch("generic")) + : matched; + // Extract and normalize the function name const functionName = normalizeFunctionName(func.name?.getText()); const functionDefinition = functions.find((f) => f.identifier === functionName); - // Build and return the node function object - return buildNodeFunction(functionDefinition); + // Build and return the node function candidate + return {value: buildNodeFunction(functionDefinition), certainty}; }; /** @@ -105,6 +121,10 @@ const createNodeFunctionIfCompatible = ( * If no base constraint exists, it falls back to the `any` type. Otherwise, it returns * the type as-is. * + * Both outcomes are a weakening the caller records as a `possible` match: the + * constraint only bounds the instantiation, and the `any` fallback matches every + * slot at all. + * * @param {ts.TypeChecker} checker - The TypeScript type checker * @param {ts.Type} returnType - The return type to resolve * diff --git a/src/util/references.util.ts b/src/util/references.util.ts index c7a19a9..e20ecbd 100644 --- a/src/util/references.util.ts +++ b/src/util/references.util.ts @@ -1,5 +1,6 @@ import ts from "typescript"; import {ReferencePath, ReferenceValue} from "@code0-tech/sagittarius-graphql-types"; +import {SuggestionCandidate, SuggestionCertainty, typeMatchCertainty} from "./suggestion.util"; /** * Extracts reference values from a collection of TypeScript symbols. @@ -12,7 +13,9 @@ import {ReferencePath, ReferenceValue} from "@code0-tech/sagittarius-graphql-typ * @param node - The VariableDeclaration context in which symbols are analyzed * @param paramType - The expected parameter type for property matching during extraction * @param symbols - The array of symbols to process and analyze - * @returns An array of ReferenceValue objects representing extracted references with their paths + * @returns An array of reference candidates representing extracted references with + * their paths, each carrying how certainly the type at that path produces + * the expected type * * @example * // Extracts all references from symbols prefixed with node_, p_, or flow_ @@ -23,7 +26,7 @@ export const getReferences = ( node: ts.VariableDeclaration, paramType: ts.Type, symbols: ts.Symbol[] -): ReferenceValue[] => { +): SuggestionCandidate[] => { return symbols.flatMap((symbol) => { const name = symbol.getName(); @@ -76,14 +79,14 @@ const isValidSymbolPrefix = (name: string): boolean => { * @param symbolType - The TypeScript type of the symbol * @param checker - TypeScript TypeChecker for type operations * @param paramType - The expected parameter type for property matching - * @returns Array of ReferenceValue objects for this node symbol + * @returns Array of reference candidates for this node symbol */ const processNodeSymbol = ( name: string, symbolType: ts.Type, checker: ts.TypeChecker, paramType: ts.Type -): ReferenceValue[] => { +): SuggestionCandidate[] => { // Skip void types if ((symbolType.flags & ts.TypeFlags.Void) !== 0) { return []; @@ -95,7 +98,7 @@ const processNodeSymbol = ( // Extract all properties that match the expected parameter type const propertyPaths = extractObjectProperties(symbolType, checker, paramType); - return propertyPaths.flatMap(({ path }) => { + return propertyPaths.map(({ path, certainty }) => { const referenceValue: ReferenceValue = { __typename: "ReferenceValue", nodeFunctionId: nodeFunctionId as any, @@ -105,7 +108,7 @@ const processNodeSymbol = ( referenceValue.referencePath = path; } - return referenceValue; + return { value: referenceValue, certainty }; }); }; @@ -120,14 +123,14 @@ const processNodeSymbol = ( * @param symbolType - The TypeScript type of the symbol * @param checker - TypeScript TypeChecker for type operations * @param paramType - The expected parameter type for property matching - * @returns Array of ReferenceValue objects for this parameter symbol + * @returns Array of reference candidates for this parameter symbol */ const processParameterSymbol = ( name: string, symbolType: ts.Type, checker: ts.TypeChecker, paramType: ts.Type -): ReferenceValue[] => { +): SuggestionCandidate[] => { // Decode parameter information from symbol name const { nodeFunctionId, paramIndexFromName } = decodeParameterName(name); @@ -142,7 +145,7 @@ const processParameterSymbol = ( return typeArguments.flatMap((tupleElementType, tupleIndex) => { const propertyPaths = extractObjectProperties(tupleElementType, checker, paramType); - return propertyPaths.flatMap(({ path }) => { + return propertyPaths.map(({ path, certainty }) => { const referenceValue: ReferenceValue = { __typename: "ReferenceValue", nodeFunctionId: nodeFunctionId as any, @@ -157,7 +160,7 @@ const processParameterSymbol = ( referenceValue.referencePath = path; } - return referenceValue; + return { value: referenceValue, certainty }; }); }); }; @@ -171,16 +174,16 @@ const processParameterSymbol = ( * @param symbolType - The TypeScript type of the symbol * @param checker - TypeScript TypeChecker for type operations * @param paramType - The expected parameter type for property matching - * @returns Array of ReferenceValue objects for this flow symbol + * @returns Array of reference candidates for this flow symbol */ const processFlowSymbol = ( symbolType: ts.Type, checker: ts.TypeChecker, paramType: ts.Type -): ReferenceValue[] => { +): SuggestionCandidate[] => { const propertyPaths = extractObjectProperties(symbolType, checker, paramType); - return propertyPaths.flatMap(({ path }) => { + return propertyPaths.map(({ path, certainty }) => { const referenceValue: ReferenceValue = { __typename: "ReferenceValue", nodeFunctionId: null, @@ -190,7 +193,7 @@ const processFlowSymbol = ( referenceValue.referencePath = path; } - return referenceValue; + return { value: referenceValue, certainty }; }); }; @@ -289,41 +292,13 @@ export const isRecursiveType = ( * @param currentPath - The current property path being built during recursion (default: empty array) * @param visited - Object types already on the current traversal branch, used to break cycles in recursive data types * @param recursionCache - Memoization cache for isRecursiveType, shared across the whole traversal - * @returns An array of objects containing the property path and the type at that path + * @returns An array of objects containing the property path, the type at that path + * and how certainly that type produces the expected type * * @example * // For type {user: {name: string, age: number}} with expectedType = string - * // Returns: [{path: [{path: 'user'}, {path: 'name'}], type: stringType}] + * // Returns: [{path: [{path: 'user'}, {path: 'name'}], type: stringType, certainty: {match: "exact"}}] */ -/** - * Determines whether a candidate type matches an expected parameter type. - * - * A plain type matches when it is (non-nullably) assignable to the expected type. - * A union matches when ANY of its constituents matches: a union such as - * `string | { deep: string }` is not assignable to `string` as a whole, yet its - * string branch makes the union key a valid suggestion for a string parameter. - * - * @param type - The candidate type (already non-nullable) - * @param checker - TypeScript TypeChecker for type operations - * @param expectedType - The target parameter type to match against - * @returns True if the type (or one of its union branches) matches the expected type - */ -const matchesExpectedType = ( - type: ts.Type, - checker: ts.TypeChecker, - expectedType: ts.Type -): boolean => { - if (type.isUnion()) { - return type.types.some((constituent) => - matchesExpectedType(checker.getNonNullableType(constituent), checker, expectedType) - ); - } - - return ( - (type.flags & ts.TypeFlags.Never) === 0 && - checker.isTypeAssignableTo(type, expectedType) - ); -}; /** * Resolves the object types whose properties should be traversed for a candidate. @@ -355,23 +330,18 @@ const extractObjectProperties = ( currentPath: ReferencePath[] = [], visited: Set = new Set(), recursionCache: Map = new Map() -): Array<{ path: ReferencePath[]; type: ts.Type }> => { - const results: Array<{ path: ReferencePath[]; type: ts.Type }> = []; - - // Check if the current type matches the expected type. The nullish part of a - // candidate is ignored: a reference typed `string | null` (or an optional - // property `text?: string | null`) is still a valid suggestion for a plain - // `string` parameter — strict assignability would reject it under strictNullChecks. - // A purely nullish candidate strips down to `never` (assignable to anything), - // so it must be excluded explicitly. - // - // A union candidate (e.g. `TEXT | { deep: TEXT }`) matches when ANY of its - // constituents is assignable to the expected type: the whole union is not - // assignable to `string`, but its string branch is, so the union key is still a - // valid string suggestion. +): Array<{ path: ReferencePath[]; type: ts.Type; certainty: SuggestionCertainty }> => { + const results: Array<{ path: ReferencePath[]; type: ts.Type; certainty: SuggestionCertainty }> = []; + + // Whether the type at this path produces the expected type, and how certainly, + // is {@link typeMatchCertainty}'s rule: a nullable reference (`string | null`, + // an optional `text?: string`) and a union key whose branches do not all fit + // (`TEXT | { deep: TEXT }` for a TEXT slot) are still offered here, as + // `nullable` / `union-member` possible matches. const nonNullableType = checker.getNonNullableType(type); - if (matchesExpectedType(nonNullableType, checker, expectedType)) { - results.push({ path: currentPath, type }); + const certainty = typeMatchCertainty(type, checker, expectedType); + if (certainty) { + results.push({ path: currentPath, type, certainty }); } // Recursively traverse into object properties. Traversal also runs on the diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 0a56499..ac06226 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -2,14 +2,26 @@ import ts, {FunctionDeclaration} from "typescript"; import {getValues} from "./values.util"; import {getReferences, isRecursiveType} from "./references.util"; import {getNodes} from "./nodes.util"; -import { - FunctionDefinition, - LiteralValue, - NodeFunction, - ReferenceValue, - SubFlowValue, -} from "@code0-tech/sagittarius-graphql-types"; +import {FunctionDefinition, LiteralValue} from "@code0-tech/sagittarius-graphql-types"; import {getSubFlows} from "./subflows.util"; +import { + exactCandidates, + strongerCertainty, + SuggestionCandidate, + SuggestionCertainty, + suggestionCandidates, + suggestionFields, + suggestionPair, + SuggestionValue, +} from "./suggestion.util"; + +export type { + SuggestionCandidate, + SuggestionCertainty, + SuggestionMatch, + SuggestionMatchReason, + SuggestionValue, +} from "./suggestion.util"; /** @@ -76,7 +88,27 @@ export interface Input { */ type?: string; /** Array of suggested values (functions, references, or literals) */ - suggestions?: (NodeFunction | ReferenceValue | LiteralValue | SubFlowValue)[]; + suggestions?: SuggestionValue[]; + /** + * How certainly each entry of `suggestions` produces this slot's type, + * index-aligned with it and of the same length — `suggestions[i]` is + * described by `suggestionCertainty[i]`. + * + * A suggestion is `exact` when its own type is assignable to the slot's, and + * `possible` when the match was only reached by weakening one of the two + * sides: a generic return matched through its constraint, an `any` that fits + * everything, a union whose branches do not all fit, a value that may be + * nullish where the slot is not (see {@link SuggestionCertainty}). + * + * Both kinds are listed — a `possible` candidate is often the one the user + * wants — but the split answers two questions the flat list cannot: whether a + * slot has *dedicated* producers (which a consumer may want to offer ahead of + * building a value by hand), and whether picking one is the case that may + * need a cast to type-check. + * + * Present exactly when `suggestions` is. + */ + suggestionCertainty?: SuggestionCertainty[]; } /** @@ -396,30 +428,33 @@ export const getSchema = ( // a superset and needs no such fallback. const typeForSubFlows = isSubFlow(typeForSuggestions) ? typeForSuggestions : parameterType; - // Collect all available suggestions for this parameter - const combinedSuggestions = suggestions ? { - suggestions: [ - ...getValues(typeForSuggestions, checker), - ...(node ? getReferences( - checker, - node, - typeForSuggestions, - checker.getSymbolsInScope(node, ts.SymbolFlags.Variable) - ) : []), - ...getNodes( - checker, - functionDeclarations, - functions, - typeForSuggestions - ), - ...getSubFlows( - checker, - functionDeclarations, - functions, - typeForSubFlows - ), - ], - } : {}; + // Collect all available suggestions for this parameter, each with how + // certainly it produces this slot's type. Literal options come straight out of + // the slot's own type and are therefore always exact; the three + // assignability-based sources report what had to be weakened to make them fit. + const candidates: SuggestionCandidate[] = suggestions ? [ + ...exactCandidates(getValues(typeForSuggestions, checker)), + ...(node ? getReferences( + checker, + node, + typeForSuggestions, + checker.getSymbolsInScope(node, ts.SymbolFlags.Variable) + ) : []), + ...getNodes( + checker, + functionDeclarations, + functions, + typeForSuggestions + ), + ...getSubFlows( + checker, + functionDeclarations, + functions, + typeForSubFlows + ), + ] : []; + + const combinedSuggestions = suggestions ? suggestionPair(candidates) : {}; // A slot whose *declared* type is a type parameter constrained by a // custom-input data type (e.g. a REST trigger's `` payload, @@ -746,9 +781,10 @@ const SPECIALIZED_LIST_INPUTS = new Set([ * 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. + * - Drops an empty `suggestions` array (and its `suggestionCertainty` twin). Node + * schemas are built with suggestions enabled and therefore always carry the + * keys — even when empty — whereas the rest of the pipeline omits them + * entirely when there are none. * * The function-side schema still drives the final input kind in * {@link mergeSchemas}. @@ -783,7 +819,7 @@ export const normalizeNodeSchema = (schema: Schema): Schema => { } if (result.suggestions && result.suggestions.length === 0) { - const {suggestions, ...rest} = result; + const {suggestions, suggestionCertainty, ...rest} = result; result = rest; } @@ -808,19 +844,23 @@ export const nonNullishType = (type: ts.Type): ts.Type => { }; /** - * Sets a schema's `suggestions` to the given set, dropping the key entirely when - * the set is empty or missing. Suggestion-carrying schemas are built with the - * key always present (an empty array when nothing matched), whereas the rest of - * the pipeline omits it — so every schema that is spread into a result passes - * through here instead of relying on the spread. + * Sets a schema's suggestions to the given candidates, dropping both the + * `suggestions` key and its `suggestionCertainty` twin entirely when the set is + * empty or missing. Suggestion-carrying schemas are built with the keys always + * present (empty arrays when nothing matched), whereas the rest of the pipeline + * omits them — so every schema that is spread into a result passes through here + * instead of relying on the spread. + * + * Taking candidates rather than two parallel arrays is what keeps the pair + * aligned: there is no call site that can set one without the other. */ export const withSuggestions = ( schema: T, - suggestions: Input["suggestions"] | undefined, + candidates: SuggestionCandidate[] | undefined, ): T => { - if (suggestions && suggestions.length > 0) return {...schema, suggestions}; + if (candidates && candidates.length > 0) return {...schema, ...suggestionFields(candidates)}; if (schema.suggestions === undefined) return schema; - const {suggestions: _dropped, ...rest} = schema; + const {suggestions: _dropped, suggestionCertainty: _droppedCertainty, ...rest} = schema; return rest as T; }; @@ -837,7 +877,7 @@ export const withSuggestions = ( */ export const declaredSchema = ( schema: Schema, - anySuggestions: Input["suggestions"] = undefined, + anySuggestions: SuggestionCandidate[] | undefined = undefined, ): Schema => { let result: Schema = schema; @@ -870,7 +910,7 @@ export const declaredSchema = ( return result.input === "generic" ? withSuggestions(result, anySuggestions) - : withSuggestions(result, result.suggestions); + : withSuggestions(result, suggestionCandidates(result)); }; /** @@ -901,7 +941,7 @@ export const declaredSchema = ( */ export const declaredItemsOf = ( functionItems: Schema[] | undefined, - anySuggestions: Input["suggestions"] = undefined, + anySuggestions: SuggestionCandidate[] | undefined = undefined, ): Schema[] => functionItems && functionItems.length > 0 ? functionItems.map((item) => declaredSchema(item, anySuggestions)) @@ -909,7 +949,7 @@ export const declaredItemsOf = ( export const genericNodeSchema = ( schema: Schema, - anySuggestions: Input["suggestions"] = undefined, + anySuggestions: SuggestionCandidate[] | undefined = undefined, overrideRoot: boolean = true, ): Schema => { let result = demoteSelect(schema); @@ -942,12 +982,7 @@ export const genericNodeSchema = ( } if (overrideRoot) { - if (anySuggestions && anySuggestions.length > 0) { - result = {...result, suggestions: anySuggestions}; - } else if (result.suggestions) { - const {suggestions, ...rest} = result; - result = rest; - } + result = withSuggestions(result, anySuggestions); } return result; @@ -957,7 +992,7 @@ export const mergeSchemas = ( functionSchema: Schema | undefined, nodeSchema: Schema, valueProvided: boolean = false, - anySuggestions: Input["suggestions"] = undefined, + anySuggestions: SuggestionCandidate[] | undefined = undefined, topLevel: boolean = true, ): Schema => { // A function-less or fully generic slot constrains nothing here: the value @@ -989,11 +1024,9 @@ export const mergeSchemas = ( // the node side only fills in for positions the declared type does not // describe. That keeps a nested slot's suggestions identical whether or not a // value has been entered. - const suggestions = dedupeSuggestions( - topLevel - ? nodeSchema.suggestions - : (functionSchema.suggestions ?? nodeSchema.suggestions), - ); + const suggestions = dedupeSuggestions(suggestionCandidates( + topLevel || functionSchema.suggestions === undefined ? nodeSchema : functionSchema, + )); if (functionSchema.input === "data") { const fProps = functionSchema.properties ?? {}; @@ -1080,7 +1113,7 @@ export const mergeSchemas = ( const mergeProperty = ( f: Schema | Schema[] | undefined, n: Schema | Schema[] | undefined, - anySuggestions: Input["suggestions"] = undefined, + anySuggestions: SuggestionCandidate[] | undefined = undefined, ): Schema | Schema[] => { // 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` @@ -1109,23 +1142,28 @@ const mergeProperty = ( }; /** - * The suggestion set of a merged position: de-duplicated by structural equality, - * and `undefined` when nothing is left so the key is omitted rather than emitted - * empty. + * The suggestion set of a merged position: de-duplicated by the structural + * equality of the suggested *values*, and `undefined` when nothing is left so + * the keys are omitted rather than emitted empty. + * + * The same value can arrive twice with different certainties — the two sides of + * a merge collected it against differently scoped types — in which case the + * stronger certainty wins: the slot does accept it for the better of the two + * reasons. */ const dedupeSuggestions = ( - suggestions: Input["suggestions"], -): Input["suggestions"] | undefined => { - if (!suggestions || suggestions.length === 0) return undefined; - const seen = new Set(); - const result: NonNullable = []; - for (const item of suggestions) { - const key = JSON.stringify(item); - if (seen.has(key)) continue; - seen.add(key); - result.push(item); + candidates: SuggestionCandidate[] | undefined, +): SuggestionCandidate[] | undefined => { + if (!candidates || candidates.length === 0) return undefined; + const byValue = new Map(); + for (const candidate of candidates) { + const key = JSON.stringify(candidate.value); + const seen = byValue.get(key); + byValue.set(key, seen + ? {...seen, certainty: strongerCertainty(seen.certainty, candidate.certainty)} + : candidate); } - return result; + return [...byValue.values()]; }; // Generic means "we extracted nothing structural from either side". That is the @@ -1137,21 +1175,23 @@ const dedupeSuggestions = ( // 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", - ...(schema.type ? {type: schema.type} : {}), - properties: {}, - required: [], - ...(schema.suggestions ? {suggestions: schema.suggestions} : {}), - }; + return withSuggestions( + { + input: "data", + ...(schema.type ? {type: schema.type} : {}), + properties: {}, + required: [], + } as Schema, + suggestionCandidates(schema), + ); }; const demoteSelect = (schema: Schema): Schema => { if (schema.input !== "select") return schema; - const suggestions = schema.suggestions ?? []; + const candidates = suggestionCandidates(schema); const literalKinds = new Set(); - for (const s of suggestions) { - const value = (s as LiteralValue).value; + for (const candidate of candidates) { + const value = (candidate.value as LiteralValue).value; const kind = typeof value; if (kind === "string" || kind === "number" || kind === "boolean") { literalKinds.add(kind); @@ -1167,11 +1207,13 @@ const demoteSelect = (schema: Schema): Schema => { } as const )[[...literalKinds][0] as "string" | "number" | "boolean"] : "text"; - return { - input: target, - ...(schema.type ? {type: schema.type} : {}), - ...(suggestions.length > 0 ? {suggestions} : {}), - }; + return withSuggestions( + { + input: target, + ...(schema.type ? {type: schema.type} : {}), + } as Schema, + candidates, + ); }; /** diff --git a/src/util/subflows.util.ts b/src/util/subflows.util.ts index 39669ca..a149f9e 100644 --- a/src/util/subflows.util.ts +++ b/src/util/subflows.util.ts @@ -1,6 +1,7 @@ import ts from "typescript"; import {FunctionDefinition, SubFlowValue, SubFlowValueSetting} from "@code0-tech/sagittarius-graphql-types"; import {isSubFlow} from "./schema.util"; +import {SuggestionCandidate, typeMatchCertainty} from "./suggestion.util"; /** * Extracts and creates a list of SubFlowValue objects from function declarations @@ -15,23 +16,24 @@ import {isSubFlow} from "./schema.util"; * @param {FunctionDefinition[]} functions - Array of function definitions for reference lookup * @param {ts.Type} paramType - The target parameter type that functions must be compatible with * - * @returns {SubFlowValue[]} An array of SubFlowValue objects for compatible functions, - * or an empty array if the paramType is not a sub-flow type + * @returns {SuggestionCandidate[]} An array of sub-flow candidates for compatible + * functions, each carrying how certainly it satisfies the slot, or an + * empty array if the paramType is not a sub-flow type */ export const getSubFlows = ( checker: ts.TypeChecker, functionDeclarations: ts.FunctionDeclaration[], functions: FunctionDefinition[], paramType: ts.Type -): SubFlowValue[] => { +): SuggestionCandidate[] => { if (!isSubFlow(paramType)) { return []; } return functionDeclarations.flatMap((func) => { - const subFlow = createSubFlowIfCompatible(checker, func, functions, paramType); - return subFlow ? [subFlow] : []; + const candidate = createSubFlowIfCompatible(checker, func, functions, paramType); + return candidate ? [candidate] : []; }); } @@ -50,23 +52,26 @@ export const getSubFlows = ( * @param {FunctionDefinition[]} functions - Array of function definitions for lookup * @param {ts.Type} paramType - The expected parameter type to check compatibility against * - * @returns {SubFlowValue | null} A SubFlowValue object if the function is compatible - * with the paramType and a matching function definition exists, or null otherwise + * @returns {SuggestionCandidate | null} A sub-flow candidate with its certainty if the + * function is compatible with the paramType and a matching function + * definition exists, or null otherwise */ const createSubFlowIfCompatible = ( checker: ts.TypeChecker, func: ts.FunctionDeclaration, functions: FunctionDefinition[], paramType: ts.Type -): SubFlowValue | null => { +): SuggestionCandidate | null => { // Get the full function type from the declaration const functionType = checker.getTypeAtLocation(func); - // Check if the function type is assignable to the parameter type - // This ensures the function signature matches the expected interface - // e.g., paramType might be (number: number) => void, and functionType should be compatible - if (!checker.isTypeAssignableTo(functionType, paramType)) { + // Check whether the function type fits the parameter type: the signature must + // match the expected interface — e.g., paramType might be + // (number: number) => void. A binding that only fits under a condition carries + // that condition as its certainty (see {@link typeMatchCertainty}). + const certainty = typeMatchCertainty(functionType, checker, paramType); + if (!certainty) { return null; } @@ -78,7 +83,7 @@ const createSubFlowIfCompatible = ( return null; } - return buildSubFlowValue(functionDefinition); + return {value: buildSubFlowValue(functionDefinition), certainty}; } diff --git a/src/util/suggestion.util.ts b/src/util/suggestion.util.ts new file mode 100644 index 0000000..83d7abe --- /dev/null +++ b/src/util/suggestion.util.ts @@ -0,0 +1,239 @@ +import ts from "typescript"; +import {LiteralValue, NodeFunction, ReferenceValue, SubFlowValue} from "@code0-tech/sagittarius-graphql-types"; + +/** + * A value a slot can be filled with: a literal option of the slot's own type, a + * reference to something already in scope, a function node whose result is fed + * into the slot, or a function bound as a sub-flow. + */ +export type SuggestionValue = NodeFunction | ReferenceValue | LiteralValue | SubFlowValue; + +/** + * How certain it is that a suggestion actually produces the slot's type. + * + * - `exact`: the candidate's own type is assignable to the slot's type. Picking + * it always yields a value the slot accepts. + * - `possible`: the match was only reached by weakening one of the two sides — + * the candidate *can* produce a fitting value, but is not guaranteed to. See + * {@link SuggestionMatchReason} for what was weakened. + * + * Both kinds are offered: a `possible` candidate is frequently the one the user + * wants (a generic `std::object::get`, a reference into an optional field). The + * distinction exists so a consumer can tell "this slot has dedicated producers" + * from "everything happens to fit here", and because a `possible` pick is the + * case that may need a cast to type-check. + */ +export type SuggestionMatch = "exact" | "possible"; + +/** + * Why a suggestion matched only possibly. + * + * - `unconstrained`: one of the two sides constrains nothing (`any`, `unknown`, + * `{}`) — the candidate fits every slot, or the slot is fit by every candidate. + * Either way the match says nothing about this pairing. + * - `generic`: the candidate's type is a type parameter and the match was made + * against its constraint. A concrete instantiation may be narrower. + * - `union-member`: the candidate is a union and only some of its members fit. + * Which member materializes is a runtime decision. + * - `nullable`: the candidate may be `null`/`undefined` where the slot is not. + * + * Listed weakest first: a candidate weakened on several axes reports the first + * of these that applies (see {@link weakestCertainty}). + */ +export type SuggestionMatchReason = "unconstrained" | "generic" | "union-member" | "nullable"; + +const REASONS_WEAKEST_FIRST: SuggestionMatchReason[] = + ["unconstrained", "generic", "union-member", "nullable"]; + +/** + * The certainty of a single suggestion. `reason` is present exactly when + * `match` is `possible`. + */ +export interface SuggestionCertainty { + match: SuggestionMatch; + reason?: SuggestionMatchReason; +} + +/** + * A suggestion together with its certainty — the form suggestions travel in + * while a schema is being built. + * + * On the finished schema the two halves are split into the index-aligned + * `suggestions` / `suggestionCertainty` pair (see {@link Input}) so the + * suggestion values stay plain GraphQL values a consumer can use as-is. Every + * step in between passes candidates around instead of the two arrays, so the + * alignment cannot drift. + */ +export interface SuggestionCandidate { + value: SuggestionValue; + certainty: SuggestionCertainty; +} + +/** The certainty of a candidate that is guaranteed to fit its slot. */ +export const EXACT_MATCH: SuggestionCertainty = {match: "exact"}; + +/** The certainty of a candidate that fits only under the given condition. */ +export const possibleMatch = (reason: SuggestionMatchReason): SuggestionCertainty => ({ + match: "possible", + reason, +}); + +/** Pairs each value with {@link EXACT_MATCH}, for sources that cannot be weakened. */ +export const exactCandidates = (values: SuggestionValue[]): SuggestionCandidate[] => + values.map((value) => ({value, certainty: EXACT_MATCH})); + +/** + * Picks the stronger of two certainties for the same value — `exact` wins, and + * among two `possible` ones the first is kept. Used when the same suggestion is + * collected twice against differently scoped types (see the merge in + * getSignatureSchema): the slot accepts it for the better of the two reasons. + */ +export const strongerCertainty = ( + a: SuggestionCertainty, + b: SuggestionCertainty, +): SuggestionCertainty => (a.match === "exact" || b.match !== "exact" ? a : b); + +/** + * Picks the weakest of the given certainties: a candidate weakened on more than + * one axis (e.g. a generic return that also turns out to be `any`) is only as + * strong as its weakest link. `exact` is the result only when nothing was + * weakened at all. + */ +export const weakestCertainty = (...certainties: SuggestionCertainty[]): SuggestionCertainty => { + const weakest = REASONS_WEAKEST_FIRST.find((reason) => + certainties.some((certainty) => certainty.reason === reason)); + + return weakest ? possibleMatch(weakest) : EXACT_MATCH; +}; + +/** + * True for a type that constrains nothing, so an assignability test against it + * is vacuous: `any` and `unknown`, the empty object type — `{}`, which every + * non-nullish value satisfies and which is what a generic `OBJECT` or an + * uninstantiated payload bound resolves to — and an object whose only structure + * is an index signature that says nothing about its values either. That last + * case is what an `OBJECT` instantiated without an argument resolves to: + * `{[key: string]: any}`, a slot every object fits. A signature with a real + * value type (`Record`) does constrain, and keeps its slot + * concrete. + * + * It matters on both sides of a match. A candidate of this type fits every slot, + * and a *slot* of this type is fitted by every candidate — the second case is + * the one that would otherwise report a hundred exact "producers" for a slot + * that simply accepts anything. + * + * A callable, an array and anything with properties is excluded: those + * constrain their values. An index signature is inspected only `depth` levels + * deep (one, from the outside) because an index type may refer back to its own + * container (`type Json = {[key: string]: Json}`). + */ +export const isUnconstrainedType = (type: ts.Type | undefined, depth: number = 1): boolean => { + // A missing index signature constrains nothing about values behind it either. + if (type === undefined) return true; + if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true; + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + + return type.getProperties().length === 0 + && type.getCallSignatures().length === 0 + && type.getConstructSignatures().length === 0 + && (depth === 0 + ? type.getNumberIndexType() === undefined && type.getStringIndexType() === undefined + : isUnconstrainedType(type.getNumberIndexType(), depth - 1) + && isUnconstrainedType(type.getStringIndexType(), depth - 1)); +}; + +/** + * Decides whether a candidate type fits an expected (slot) type, and how + * certainly — the shared rule behind every assignability-based suggestion + * source. + * + * Returns `null` when the candidate does not fit at all. Otherwise the match is + * `exact`, or `possible` with the reason the fit is not guaranteed: + * + * - either side constrains nothing (`any`, `unknown`, `{}`) → `unconstrained`. + * For the candidate that means it fits everywhere; for the slot that it is + * fitted by everything. Neither tells the consumer that this candidate is a + * producer *of this type*, which is the whole point of the distinction. This + * only *weakens* the verdict — whether the candidate fits at all is still + * decided by assignability, so an open candidate is not waved through into a + * slot that would reject it; + * - the candidate is a union and only some members fit → `union-member`. A + * union is only assignable as a whole when *every* member is, so the partial + * case is exactly the one strict assignability would reject while the slot can + * still be served at runtime; + * - the candidate may be nullish where the slot is not → `nullable`. The nullish + * part is deliberately ignored for matching (a `string | null` producer is a + * useful suggestion for a `string` slot), which is what makes the distinction + * worth reporting. + * + * A purely nullish candidate strips down to `never`, which is assignable to + * anything, and is therefore rejected explicitly. + */ +export const typeMatchCertainty = ( + type: ts.Type, + checker: ts.TypeChecker, + expectedType: ts.Type, +): SuggestionCertainty | null => { + const nonNullable = checker.getNonNullableType(type); + + if ((nonNullable.flags & ts.TypeFlags.Never) !== 0) return null; + + // A non-union candidate is its own single member, which makes the partial + // match below impossible for it and the assignability test the same one. + const members = nonNullable.isUnion() + ? nonNullable.types.map((member) => checker.getNonNullableType(member)) + : [nonNullable]; + const matching = members.filter((member) => + (member.flags & ts.TypeFlags.Never) === 0 + && checker.isTypeAssignableTo(member, expectedType)); + + if (matching.length === 0) return null; + + return weakestCertainty( + isUnconstrainedType(nonNullable) || isUnconstrainedType(expectedType) + ? possibleMatch("unconstrained") + : EXACT_MATCH, + nonNullable !== type && !checker.isTypeAssignableTo(type, expectedType) + ? possibleMatch("nullable") + : EXACT_MATCH, + matching.length < members.length ? possibleMatch("union-member") : EXACT_MATCH, + ); +}; + +/** + * Reads a schema's suggestions back as candidates. A schema that carries + * suggestions without certainties (produced before the pair existed, or hand + * written) is read as fully exact rather than rejected. + */ +export const suggestionCandidates = (schema: { + suggestions?: SuggestionValue[]; + suggestionCertainty?: SuggestionCertainty[]; +}): SuggestionCandidate[] => + (schema.suggestions ?? []).map((value, index) => ({ + value, + certainty: schema.suggestionCertainty?.[index] ?? EXACT_MATCH, + })); + +/** + * Splits candidates into the schema's index-aligned pair of keys, both keys + * always present — the convention of a freshly collected schema, which carries + * the keys even when nothing matched (an empty array marks "this is an input + * slot and these are all its candidates", which normalizeNodeSchema then + * collapses). Use {@link suggestionFields} everywhere else. + */ +export const suggestionPair = ( + candidates: SuggestionCandidate[], +): {suggestions: SuggestionValue[]; suggestionCertainty: SuggestionCertainty[]} => ({ + suggestions: candidates.map((candidate) => candidate.value), + suggestionCertainty: candidates.map((candidate) => candidate.certainty), +}); + +/** + * Splits candidates back into the schema's index-aligned pair of keys. Both keys + * are omitted for an empty set, matching the convention that a schema without + * suggestions carries neither key. + */ +export const suggestionFields = ( + candidates: SuggestionCandidate[] | undefined, +): {suggestions?: SuggestionValue[]; suggestionCertainty?: SuggestionCertainty[]} => + candidates && candidates.length > 0 ? suggestionPair(candidates) : {}; diff --git a/test/schema/listElementProducer.test.ts b/test/schema/listElementProducer.test.ts new file mode 100644 index 0000000..8a7382e --- /dev/null +++ b/test/schema/listElementProducer.test.ts @@ -0,0 +1,59 @@ +import {describe, expect, it} from "vitest"; +import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; +import {getSignatureSchema, ListInput} from "../../src"; +import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; + +// Mirrors the `list-test` mock module: a structured entry data type, a function +// that consumes a list of them, and a function that produces exactly one. +const ENTRY: DataType = { + __typename: "DataType", + id: "gid://sagittarius/DataType/9400", + identifier: "LIST_TEST_ENTRY", + genericKeys: [], + type: "{ label: string; amount: number; color: COLOR; tags: string[] }", +} as unknown as DataType; + +const ENTRY_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9400", + identifier: "std::test::entry_list", + signature: "(entries: LIST): NUMBER", +} as FunctionDefinition; + +const CREATE_ENTRY: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9401", + identifier: "std::test::create_entry", + signature: "(label: TEXT, amount: NUMBER): LIST_TEST_ENTRY", +} as FunctionDefinition; + +describe("list element producers", () => { + it("marks the dedicated entry producer exact on the element slot", () => { + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [{ + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::test::entry_list"}, + parameters: {nodes: [{value: null}]}, + }], + }, + }; + + const result = getSignatureSchema( + flow, + [...DATA_TYPES, ENTRY], + [...FUNCTION_SIGNATURES, ENTRY_LIST, CREATE_ENTRY], + "gid://sagittarius/NodeFunction/1", + ); + + const element = (result.parameters[0].schema as ListInput).declaredItems![0]; + expect(element.input).toBe("data"); + + const exact = (element.suggestions ?? []).flatMap((value: any, index) => + element.suggestionCertainty![index].match === "exact" && value.__typename === "NodeFunction" + ? [value.functionDefinition?.identifier] : []); + + expect(exact).toEqual(["std::test::create_entry"]); + }); +}); diff --git a/test/schema/suggestionCertainty.test.ts b/test/schema/suggestionCertainty.test.ts new file mode 100644 index 0000000..a642da5 --- /dev/null +++ b/test/schema/suggestionCertainty.test.ts @@ -0,0 +1,284 @@ +import {describe, expect, it} from "vitest"; +import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; +import {getSignatureSchema, getTypeSchema, Schema, SuggestionCertainty} from "../../src"; +import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; + +/** + * `suggestions` and `suggestionCertainty` are an index-aligned pair: every + * suggestion is described by the certainty at its own index. These tests pin + * that invariant down across a whole schema tree, and the certainties the four + * suggestion sources report. + */ +describe("Suggestion certainty", () => { + + // Every schema in a tree: the node itself plus its items, declared items and + // (possibly union-valued) properties. + const allSchemas = (schema: Schema | Schema[] | undefined): Schema[] => { + if (!schema) return []; + if (Array.isArray(schema)) return schema.flatMap(allSchemas); + + return [ + schema, + ...((schema as any).items ?? []).flatMap(allSchemas), + ...((schema as any).declaredItems ?? []).flatMap(allSchemas), + ...Object.values((schema as any).properties ?? {}).flatMap((property) => + allSchemas(property as Schema | Schema[])), + ]; + }; + + const certaintyOf = (schema: Schema, predicate: (value: any) => boolean): SuggestionCertainty[] => + (schema.suggestions ?? []).flatMap((value, index) => + predicate(value) ? [schema.suggestionCertainty![index]] : []); + + const describeCertainty = (certainty: SuggestionCertainty): string => + certainty.match === "exact" ? "exact" : `possible:${certainty.reason}`; + + const respondFlow = (): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "rest::control::respond"}, + parameters: {nodes: []}, + }, + ], + }, + }); + + it("pairs every suggestion list with an equally long certainty list", () => { + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + + const schemas = result.parameters.flatMap((parameter) => allSchemas(parameter.schema)); + expect(schemas.length).toBeGreaterThan(0); + + schemas.forEach((schema) => { + expect(schema.suggestionCertainty === undefined).toBe(schema.suggestions === undefined); + expect(schema.suggestionCertainty?.length).toBe(schema.suggestions?.length); + (schema.suggestionCertainty ?? []).forEach((certainty) => { + // A reason is carried exactly for a possible match. + expect(certainty.reason === undefined).toBe(certainty.match === "exact"); + }); + }); + }); + + it("keeps the suggested values themselves free of certainty metadata", () => { + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + + const suggestions = result.parameters + .flatMap((parameter) => allSchemas(parameter.schema)) + .flatMap((schema) => schema.suggestions ?? []); + + expect(suggestions.length).toBeGreaterThan(0); + suggestions.forEach((suggestion) => { + expect(suggestion).not.toHaveProperty("certainty"); + expect(suggestion).not.toHaveProperty("value.__typename"); + }); + }); + + it("omits both keys on a schema built without suggestions", () => { + const schema = getTypeSchema("NUMBER", DATA_TYPES); + + expect(schema).toEqual({input: "number", type: "number"}); + }); + + it("marks dedicated producers of a concrete type exact and widened ones possible", () => { + // Parameter 0 of rest::control::respond is a HTTP_STATUS_CODE (a NUMBER). + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + const statusCode = result.parameters[0].schema; + + expect(statusCode.input).toBe("number"); + + // std::number::add returns NUMBER: a dedicated producer of this slot. + const add = certaintyOf(statusCode, (value) => + value.__typename === "NodeFunction" + && value.functionDefinition?.identifier === "std::number::add"); + expect(add.map(describeCertainty)).toEqual(["exact"]); + + // std::control::value returns an unconstrained type parameter, so it is + // offered everywhere and guarantees nothing about this slot. + const passThrough = certaintyOf(statusCode, (value) => + value.__typename === "NodeFunction" + && value.functionDefinition?.identifier === "std::control::value"); + expect(passThrough.map(describeCertainty)).toEqual(["possible:unconstrained"]); + }); + + it("offers no exact producer for a slot that accepts anything", () => { + // Parameter 1 is `headers: OBJECT<{}>` — an open object every value + // satisfies. Candidates are still offered, but none of them is a producer + // *of this type*, which is what the certainty says. + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + const headers = result.parameters[1].schema; + + expect(headers.input).toBe("data"); + expect(headers.suggestions?.length).toBeGreaterThan(0); + expect(headers.suggestionCertainty?.every((certainty) => + certainty.match === "possible" && certainty.reason === "unconstrained")).toBe(true); + }); + + it("offers no exact producer for a slot typed by an uninstantiated generic object", () => { + // Parameter 0 of std::object::contains_key is `OBJECT` with T unbound, + // which resolves to `{[key: string]: any}`: an open index signature every + // object satisfies. The slot is not concrete, so it has no producers of + // its own type even though the candidates are assignable to it. + const containsKeyFlow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "std::object::contains_key"}, + parameters: {nodes: []}, + }, + ], + }, + }; + + const result = getSignatureSchema( + containsKeyFlow, + DATA_TYPES, + FUNCTION_SIGNATURES, + "gid://sagittarius/NodeFunction/1", + ); + const object = result.parameters[0].schema; + + expect(object.input).toBe("data"); + expect(object.suggestions?.length).toBeGreaterThan(0); + expect(object.suggestionCertainty?.every((certainty) => certainty.match === "possible")).toBe(true); + }); + + it("marks a return type matched through its constraint as a generic match", () => { + // A function whose return type is a *bounded* type parameter: the bound + // fits the slot, but the instantiation may be narrower than the bound. + const boundedGeneric: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9200", + identifier: "custom::bounded::produce", + signature: "(value: T): T", + } as FunctionDefinition; + + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + [...FUNCTION_SIGNATURES, boundedGeneric], + "gid://sagittarius/NodeFunction/1", + ); + + const bounded = certaintyOf(result.parameters[0].schema, (value) => + value.__typename === "NodeFunction" + && value.functionDefinition?.identifier === "custom::bounded::produce"); + + expect(bounded.map(describeCertainty)).toEqual(["possible:generic"]); + }); + + it("marks a producer that may yield nothing as a nullable match", () => { + // The nullish part of a return type is ignored when matching — such a + // function is a useful suggestion for a non-null slot — so the certainty + // is where "may be undefined" is reported. + const maybeNumber: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9300", + identifier: "custom::maybe::produce", + signature: "(): NUMBER | undefined", + } as FunctionDefinition; + + const result = getSignatureSchema( + respondFlow(), + DATA_TYPES, + [...FUNCTION_SIGNATURES, maybeNumber], + "gid://sagittarius/NodeFunction/1", + ); + + const maybe = certaintyOf(result.parameters[0].schema, (value) => + value.__typename === "NodeFunction" + && value.functionDefinition?.identifier === "custom::maybe::produce"); + + expect(maybe.map(describeCertainty)).toEqual(["possible:nullable"]); + }); + + it("marks a reference into a union key as a union-member match", () => { + // UNION_HOLDER.flexible is `TEXT | { deep: TEXT }`. For a TEXT slot the + // union is offered because its string branch fits — which branch + // materializes is a runtime decision, so the match is only possible. + // Its validation twin is the warning getFlowValidation reports for + // actually using such a reference. + const unionHolder: DataType = { + __typename: "DataType", + id: "gid://sagittarius/DataType/9200", + identifier: "UNION_HOLDER", + genericKeys: [], + type: "{ flexible: TEXT | { deep: TEXT } }", + } as unknown as DataType; + + const produce: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9201", + identifier: "custom::union::produce", + signature: "(): UNION_HOLDER", + } as FunctionDefinition; + + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "custom::union::produce"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: []}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::text::split"}, + parameters: {nodes: [{value: null}, {value: null}]}, + }, + ], + }, + }; + + const result = getSignatureSchema( + flow, + [...DATA_TYPES, unionHolder], + [...FUNCTION_SIGNATURES, produce], + "gid://sagittarius/NodeFunction/2", + ); + + const isFlexible = (value: any, path: string[]) => + value.__typename === "ReferenceValue" + && value.nodeFunctionId === "gid://sagittarius/NodeFunction/1" + && JSON.stringify((value.referencePath ?? []).map((segment: any) => segment.path)) === JSON.stringify(path); + + const schema = result.parameters[0].schema; + + // The union key itself: only one of its branches is a TEXT. + expect(certaintyOf(schema, (value) => isFlexible(value, ["flexible"])).map(describeCertainty)) + .toEqual(["possible:union-member"]); + + // The string inside the object branch is a TEXT outright. + expect(certaintyOf(schema, (value) => isFlexible(value, ["flexible", "deep"])).map(describeCertainty)) + .toEqual(["exact"]); + }); +});