Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions src/schema/getSignatureSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ const generateNodeSchemas = (
// setting a boolean literal in a generic slot would silently hide all
// other suggestions.
const suggestionType = functionParameterType
? widenForSuggestions(checker, functionParameterType)
? widenForSuggestions(checker, functionParameterType, node!)
: undefined

const nodeSchema = getSchema(
Expand Down Expand Up @@ -375,13 +375,51 @@ const generateNodeSchemas = (
// 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)
// parameter is replaced by its constraint. Any generic type with free type parameters
// (e.g. LIST<T>, OBJECT<T>) is widened by substituting `any` for each free
// TypeParameter, because TypeScript's isTypeAssignableTo returns false for concrete
// types against generics with free T even though they are structurally valid candidates.
//
// For array types (TypeReference) the public createArrayType API rebuilds the widened
// type directly. For type-alias types (e.g. mapped types like OBJECT<T>) the source
// code is pre-seeded with `declare const __widen_<Name>: <Name><any, …>` declarations
// so the widened type can be looked up in the checker's scope via node.
const widenForSuggestions = (checker: ts.TypeChecker, type: ts.Type, node: ts.VariableDeclaration): ts.Type => {
if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) {
const decl = type.symbol?.declarations?.[0]
if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) {
return checker.getTypeFromTypeNode(decl.constraint)
}
return checker.getAnyType()
}

if ((type.flags & ts.TypeFlags.Object) !== 0 && hasFreeTypeParam(type, checker, new Set())) {
const aliasName: string | undefined = (type as any).aliasSymbol?.getName()
if (aliasName) {
const widenedSym = checker
.getSymbolsInScope(node, ts.SymbolFlags.Variable)
.find(s => s.getName() === `__widen_${aliasName}`)
if (widenedSym) {
return checker.getTypeOfSymbolAtLocation(widenedSym, node)
}
}
return checker.getAnyType()
}

return type
}

// Returns true if type itself or any of its type arguments (direct or alias) is a
// free TypeParameter, recursing into nested generic types.
const hasFreeTypeParam = (type: ts.Type, checker: ts.TypeChecker, visited: Set<ts.Type>): boolean => {
if (visited.has(type)) return false
visited.add(type)
if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) return true
if ((type.flags & ts.TypeFlags.Object) !== 0) {
if (checker.getTypeArguments(type as ts.TypeReference).some(arg => hasFreeTypeParam(arg, checker, visited))) return true
const aliasArgs = (type as any).aliasTypeArguments as ts.Type[] | undefined
if (aliasArgs?.some(arg => hasFreeTypeParam(arg, checker, visited))) return true
}
return checker.getAnyType()
return false
}

10 changes: 9 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,15 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s
`type ${dt.identifier}${(dt.genericKeys?.length ?? 0) > 0 ? `<${dt.genericKeys?.join(",")}>` : ""} = ${dt.type};`
).join("\n");

return `${useGenericDeclarations ? genericDeclarations : ""}\n${typeAliasDeclarations}`;
// Pre-instantiate every generic type with `any` for each type parameter.
// These are used by widenForSuggestions to produce the widened type for
// suggestion-scope checks when a parameter type has free TypeParameters.
const widenedDeclarations = dataTypes
?.filter(dt => (dt.genericKeys?.length ?? 0) > 0)
.map(dt => `declare const __widen_${dt.identifier}: ${dt.identifier}<${dt.genericKeys!.map(() => "any").join(", ")}>;`)
.join("\n") ?? "";

return `${useGenericDeclarations ? genericDeclarations : ""}\n${typeAliasDeclarations}\n${widenedDeclarations}`;
}

/**
Expand Down
62 changes: 62 additions & 0 deletions test/schema/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,68 @@ describe("Schema", () => {
expect(new Set(numberSet)).toEqual(new Set(objectSet));
});

it('std::control::value with array literal is offered as a LIST reference in std::list::at', () => {
// Node 1: std::control::value<T>(value: T): T receives a literal array [1, 2, 3].
// TypeScript infers T = number[], so the node's return type is number[] (LIST<NUMBER>).
//
// Node 2: std::list::at<T>(list: LIST<T>, index: NUMBER): T
// The `list` parameter expects LIST<T> (any array). number[] is assignable to
// LIST<T> (T unconstrained → upper bound unknown → number[] ⊆ unknown[]), so
// node 1 must appear as a ReferenceValue suggestion for the `list` parameter.
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::control::value"},
nextNodeId: "gid://sagittarius/NodeFunction/2",
parameters: {
nodes: [
{value: {__typename: "LiteralValue", value: [1, 2, 3]}},
],
},
},
{
id: "gid://sagittarius/NodeFunction/2",
functionDefinition: {identifier: "std::list::at"},
parameters: {
nodes: [
{value: null},
{value: {__typename: "LiteralValue", value: 0}},
],
},
},
],
},
};

const [listSchema] = getSignatureSchema(
flow,
DATA_TYPES,
FUNCTION_SIGNATURES,
"gid://sagittarius/NodeFunction/2",
);

// The `list` parameter is of type LIST<T> → must render as a list input.
expect(listSchema.schema.input).toBe("list");

// Node 1 returns LIST<NUMBER>, which is assignable to LIST<T>.
// Its return value is a direct reference (no property path).
const suggestions = (listSchema.schema.suggestions ?? []) as any[];
expect(suggestions.length).toBeGreaterThan(0);
expect(
suggestions.some(
(s) =>
s.__typename === "ReferenceValue" &&
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" &&
!s.referencePath,
),
).toBe(true);
});

it('demotes select to free-form when function parameter is generic', () => {
// std::control::return<T>(value: T): T — function declares T, node sets a
// string literal. Result must be text (free-form), NOT select with one option.
Expand Down