Skip to content

Commit 527b02d

Browse files
authored
Merge pull request #129 from code0-tech/feat/#128
Suggestions aren't corrected calculated for nested generics
2 parents c7d209c + e35d399 commit 527b02d

3 files changed

Lines changed: 117 additions & 9 deletions

File tree

src/schema/getSignatureSchema.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ const generateNodeSchemas = (
335335
// setting a boolean literal in a generic slot would silently hide all
336336
// other suggestions.
337337
const suggestionType = functionParameterType
338-
? widenForSuggestions(checker, functionParameterType)
338+
? widenForSuggestions(checker, functionParameterType, node!)
339339
: undefined
340340

341341
const nodeSchema = getSchema(
@@ -375,13 +375,51 @@ const generateNodeSchemas = (
375375
// Widen a function parameter type so that suggestion collection asks "what could
376376
// the function accept here", not "what does the current value narrow this to".
377377
// An unconstrained type parameter accepts anything → `any`. A constrained type
378-
// parameter is replaced by its constraint. Everything else is used as-is.
379-
const widenForSuggestions = (checker: ts.TypeChecker, type: ts.Type): ts.Type => {
380-
if ((type.flags & ts.TypeFlags.TypeParameter) === 0) return type
381-
const decl = type.symbol?.declarations?.[0]
382-
if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) {
383-
return checker.getTypeFromTypeNode(decl.constraint)
378+
// parameter is replaced by its constraint. Any generic type with free type parameters
379+
// (e.g. LIST<T>, OBJECT<T>) is widened by substituting `any` for each free
380+
// TypeParameter, because TypeScript's isTypeAssignableTo returns false for concrete
381+
// types against generics with free T even though they are structurally valid candidates.
382+
//
383+
// For array types (TypeReference) the public createArrayType API rebuilds the widened
384+
// type directly. For type-alias types (e.g. mapped types like OBJECT<T>) the source
385+
// code is pre-seeded with `declare const __widen_<Name>: <Name><any, …>` declarations
386+
// so the widened type can be looked up in the checker's scope via node.
387+
const widenForSuggestions = (checker: ts.TypeChecker, type: ts.Type, node: ts.VariableDeclaration): ts.Type => {
388+
if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) {
389+
const decl = type.symbol?.declarations?.[0]
390+
if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) {
391+
return checker.getTypeFromTypeNode(decl.constraint)
392+
}
393+
return checker.getAnyType()
394+
}
395+
396+
if ((type.flags & ts.TypeFlags.Object) !== 0 && hasFreeTypeParam(type, checker, new Set())) {
397+
const aliasName: string | undefined = (type as any).aliasSymbol?.getName()
398+
if (aliasName) {
399+
const widenedSym = checker
400+
.getSymbolsInScope(node, ts.SymbolFlags.Variable)
401+
.find(s => s.getName() === `__widen_${aliasName}`)
402+
if (widenedSym) {
403+
return checker.getTypeOfSymbolAtLocation(widenedSym, node)
404+
}
405+
}
406+
return checker.getAnyType()
407+
}
408+
409+
return type
410+
}
411+
412+
// Returns true if type itself or any of its type arguments (direct or alias) is a
413+
// free TypeParameter, recursing into nested generic types.
414+
const hasFreeTypeParam = (type: ts.Type, checker: ts.TypeChecker, visited: Set<ts.Type>): boolean => {
415+
if (visited.has(type)) return false
416+
visited.add(type)
417+
if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) return true
418+
if ((type.flags & ts.TypeFlags.Object) !== 0) {
419+
if (checker.getTypeArguments(type as ts.TypeReference).some(arg => hasFreeTypeParam(arg, checker, visited))) return true
420+
const aliasArgs = (type as any).aliasTypeArguments as ts.Type[] | undefined
421+
if (aliasArgs?.some(arg => hasFreeTypeParam(arg, checker, visited))) return true
384422
}
385-
return checker.getAnyType()
423+
return false
386424
}
387425

src/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,15 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s
9090
`type ${dt.identifier}${(dt.genericKeys?.length ?? 0) > 0 ? `<${dt.genericKeys?.join(",")}>` : ""} = ${dt.type};`
9191
).join("\n");
9292

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

96104
/**

test/schema/schema.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,68 @@ describe("Schema", () => {
525525
expect(new Set(numberSet)).toEqual(new Set(objectSet));
526526
});
527527

528+
it('std::control::value with array literal is offered as a LIST reference in std::list::at', () => {
529+
// Node 1: std::control::value<T>(value: T): T receives a literal array [1, 2, 3].
530+
// TypeScript infers T = number[], so the node's return type is number[] (LIST<NUMBER>).
531+
//
532+
// Node 2: std::list::at<T>(list: LIST<T>, index: NUMBER): T
533+
// The `list` parameter expects LIST<T> (any array). number[] is assignable to
534+
// LIST<T> (T unconstrained → upper bound unknown → number[] ⊆ unknown[]), so
535+
// node 1 must appear as a ReferenceValue suggestion for the `list` parameter.
536+
const flow: Flow = {
537+
id: "gid://sagittarius/Flow/1",
538+
startingNodeId: "gid://sagittarius/NodeFunction/1",
539+
signature: "(): void",
540+
nodes: {
541+
nodes: [
542+
{
543+
id: "gid://sagittarius/NodeFunction/1",
544+
functionDefinition: {identifier: "std::control::value"},
545+
nextNodeId: "gid://sagittarius/NodeFunction/2",
546+
parameters: {
547+
nodes: [
548+
{value: {__typename: "LiteralValue", value: [1, 2, 3]}},
549+
],
550+
},
551+
},
552+
{
553+
id: "gid://sagittarius/NodeFunction/2",
554+
functionDefinition: {identifier: "std::list::at"},
555+
parameters: {
556+
nodes: [
557+
{value: null},
558+
{value: {__typename: "LiteralValue", value: 0}},
559+
],
560+
},
561+
},
562+
],
563+
},
564+
};
565+
566+
const [listSchema] = getSignatureSchema(
567+
flow,
568+
DATA_TYPES,
569+
FUNCTION_SIGNATURES,
570+
"gid://sagittarius/NodeFunction/2",
571+
);
572+
573+
// The `list` parameter is of type LIST<T> → must render as a list input.
574+
expect(listSchema.schema.input).toBe("list");
575+
576+
// Node 1 returns LIST<NUMBER>, which is assignable to LIST<T>.
577+
// Its return value is a direct reference (no property path).
578+
const suggestions = (listSchema.schema.suggestions ?? []) as any[];
579+
expect(suggestions.length).toBeGreaterThan(0);
580+
expect(
581+
suggestions.some(
582+
(s) =>
583+
s.__typename === "ReferenceValue" &&
584+
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" &&
585+
!s.referencePath,
586+
),
587+
).toBe(true);
588+
});
589+
528590
it('demotes select to free-form when function parameter is generic', () => {
529591
// std::control::return<T>(value: T): T — function declares T, node sets a
530592
// string literal. Result must be text (free-form), NOT select with one option.

0 commit comments

Comments
 (0)