From 2e3f1c004e64c40f59db7d9c350504d15f04497d Mon Sep 17 00:00:00 2001 From: nicosammito Date: Wed, 8 Jul 2026 23:06:48 +0200 Subject: [PATCH 1/3] feat: add tests for nullable references in flow validation and schema suggestions --- test/flowValidation.test.ts | 116 +++++++++++++++++++++++++++++- test/schema/schema.test.ts | 139 +++++++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 2 deletions(-) diff --git a/test/flowValidation.test.ts b/test/flowValidation.test.ts index 5ae0fda..9be2345 100644 --- a/test/flowValidation.test.ts +++ b/test/flowValidation.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from 'vitest'; import {getFlowValidation} from '../src/validation/getFlowValidation'; -import {Flow} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen +import {Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen // @ts-ignore import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data"; @@ -1326,4 +1326,118 @@ describe('getFlowValidation - Integrationstest', () => { ]); }); + describe('nullable references into non-nullable parameters', () => { + + // custom::text::nullable(): TEXT | null — no parameters, returns a nullable string. + const NULLABLE_TEXT_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9001", + identifier: "custom::text::nullable", + signature: "(): TEXT | null", + }; + + // custom::text::nullable_object(): {text?: TEXT | null} — returns an object whose + // `text` property is optional and nullable. + const NULLABLE_OBJECT_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9002", + identifier: "custom::text::nullable_object", + signature: "(): {text?: TEXT | null}", + }; + + // custom::number::nullable(): NUMBER | null — nullable, but the base type is + // a number, so it must stay incompatible with a TEXT parameter. + const NULLABLE_NUMBER_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9003", + identifier: "custom::number::nullable", + signature: "(): NUMBER | null", + }; + + const CUSTOM_FUNCTIONS = [ + ...FUNCTION_SIGNATURES, + NULLABLE_TEXT_FN, + NULLABLE_OBJECT_FN, + NULLABLE_NUMBER_FN, + ]; + + // Builds a two-node flow: node 1 runs `firstIdentifier` (no parameters), + // node 2 runs std::text::split(value: TEXT, delimiter: TEXT) with `valueRef` + // as its `value` argument. + const buildFlow = (firstIdentifier: string, valueRef: any): Flow => ({ + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: firstIdentifier}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::text::split"}, + parameters: { + nodes: [ + {value: valueRef}, + {value: {__typename: "LiteralValue", value: ","}}, + ], + }, + }, + ], + }, + }); + + it('accepts a TEXT | null reference for a plain TEXT parameter', () => { + // Node 1 returns TEXT | null; node 2's `value` parameter wants TEXT. + // The nullish part of the reference must not fail the validation. + const flow = buildFlow("custom::text::nullable", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.diagnostics).toHaveLength(0); + expect(result.isValid).toBe(true); + }); + + it('accepts a nullable object property reference for a plain TEXT parameter', () => { + // Node 1 returns {text?: TEXT | null}; the reference path drills into `text` + // (TEXT | null | undefined). The nullish part must not fail the validation. + const flow = buildFlow("custom::text::nullable_object", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + referencePath: [{path: "text"}], + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.diagnostics).toHaveLength(0); + expect(result.isValid).toBe(true); + }); + + it('still rejects a NUMBER | null reference for a plain TEXT parameter', () => { + // Ignoring the nullish part must not make the base types interchangeable: + // NUMBER | null stripped to NUMBER is still not a TEXT. + const flow = buildFlow("custom::number::nullable", { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }); + + const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES); + + expect(result.isValid).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ])); + }); + + }); + }); \ No newline at end of file diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index efadaf6..fabdd45 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from "vitest"; -import {Flow} from "@code0-tech/sagittarius-graphql-types"; +import {Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; import {getSignatureSchema, getTypeSchema} from "../../src"; import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; @@ -688,4 +688,141 @@ describe("Schema", () => { ).toBe(true); }); + it('offers a nullable TEXT return as a ReferenceValue suggestion for a plain TEXT parameter', () => { + // Node 1: custom::text::nullable(): TEXT | null — no parameters, returns a + // nullable string. + // Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST + // + // The `value` parameter is declared as plain TEXT (string). Node 1's return + // type is string | null. Strict assignability would reject string | null → string, + // but the editor must still offer node 1 as a ReferenceValue suggestion here: + // the nullish part of a reference's type should be ignored for suggestion scoping. + const NULLABLE_TEXT_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9001", + identifier: "custom::text::nullable", + signature: "(): TEXT | null", + }; + + 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::text::nullable"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::text::split"}, + parameters: { + nodes: [ + {value: null}, + {value: {__typename: "LiteralValue", value: ","}}, + ], + }, + }, + ], + }, + }; + + const [valueSchema] = getSignatureSchema( + flow, + DATA_TYPES, + [...FUNCTION_SIGNATURES, NULLABLE_TEXT_FN], + "gid://sagittarius/NodeFunction/2", + ); + + // value: TEXT → free-form text input. + expect(valueSchema.schema.input).toBe("text"); + + // Node 1 returns TEXT | null; stripping the nullish part leaves TEXT, which is + // assignable to the TEXT parameter. Its return value is a direct reference + // (no property path). + const suggestions = (valueSchema.schema.suggestions ?? []) as any[]; + expect( + suggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + !s.referencePath, + ), + ).toBe(true); + }); + + it('offers a nullable object property as a ReferenceValue path suggestion for a plain TEXT parameter', () => { + // Node 1: custom::text::nullable_object(): {text?: TEXT | null} — no parameters, + // returns an object whose `text` property is optional and nullable. + // Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST + // + // The `value` parameter is declared as plain TEXT (string). Node 1's `text` + // property has type string | null | undefined. Strict assignability would + // reject it, but the editor must still offer node 1's `text` property as a + // ReferenceValue suggestion with referencePath [{path: "text"}]: the nullish + // part of a reference's type should be ignored for suggestion scoping. + const NULLABLE_OBJECT_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9002", + identifier: "custom::text::nullable_object", + signature: "(): {text?: TEXT | null}", + }; + + 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::text::nullable_object"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: { + nodes: [], + }, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "std::text::split"}, + parameters: { + nodes: [ + {value: null}, + {value: {__typename: "LiteralValue", value: ","}}, + ], + }, + }, + ], + }, + }; + + const [valueSchema] = getSignatureSchema( + flow, + DATA_TYPES, + [...FUNCTION_SIGNATURES, NULLABLE_OBJECT_FN], + "gid://sagittarius/NodeFunction/2", + ); + + // value: TEXT → free-form text input. + expect(valueSchema.schema.input).toBe("text"); + + // Node 1's `text` property is TEXT | null | undefined; stripping the nullish + // part leaves TEXT, which is assignable to the TEXT parameter → node 1 must + // be offered with referencePath [{path: "text"}]. + const suggestions = (valueSchema.schema.suggestions ?? []) as any[]; + expect( + suggestions.some( + (s) => + s.__typename === "ReferenceValue" && + s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" && + Array.isArray(s.referencePath) && + s.referencePath.length === 1 && + s.referencePath[0].path === "text", + ), + ).toBe(true); + }); + }) \ No newline at end of file From c647b9b8fb38ef7921cfb911dc3d1188d88314e9 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Wed, 8 Jul 2026 23:06:55 +0200 Subject: [PATCH 2/3] feat: enhance type checking by excluding nullish types in assignability checks --- src/util/nodes.util.ts | 12 ++++++++++-- src/util/references.util.ts | 13 +++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/util/nodes.util.ts b/src/util/nodes.util.ts index d97a64c..331efe0 100644 --- a/src/util/nodes.util.ts +++ b/src/util/nodes.util.ts @@ -79,8 +79,16 @@ 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 - if (!checker.isTypeAssignableTo(simplifiedReturnType, paramType)) { + // 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) + ) { return null; } diff --git a/src/util/references.util.ts b/src/util/references.util.ts index 987bc43..6347d27 100644 --- a/src/util/references.util.ts +++ b/src/util/references.util.ts @@ -257,8 +257,17 @@ const extractObjectProperties = ( ): Array<{ path: ReferencePath[]; type: ts.Type }> => { const results: Array<{ path: ReferencePath[]; type: ts.Type }> = []; - // Check if the current type matches the expected type - if (checker.isTypeAssignableTo(type, expectedType)) { + // 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. + const nonNullableType = checker.getNonNullableType(type); + if ( + (nonNullableType.flags & ts.TypeFlags.Never) === 0 && + checker.isTypeAssignableTo(nonNullableType, expectedType) + ) { results.push({ path: currentPath, type }); } From 55113257021f4bec7e97481761f96a9a0e8bd8be Mon Sep 17 00:00:00 2001 From: nicosammito Date: Wed, 8 Jul 2026 23:06:59 +0200 Subject: [PATCH 3/3] feat: add non-null assertion for reference type validation under strictNullChecks --- src/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index d5ff282..04b635f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -139,7 +139,11 @@ export function generateFlowSourceCode( ref.referencePath?.forEach(pathObj => { refCode += `?.${pathObj.path}`; }); - return `/* @pos ${id} ${index} */ ${refCode}`; + // Non-null assertion: a reference typed `string | null` (or an optional + // chain like `node_X?.text`) must still satisfy a plain `string` parameter + // under strictNullChecks — only the nullish part is waived, base type + // mismatches still fail validation. + return `/* @pos ${id} ${index} */ (${refCode})!`; } if (val.__typename === "LiteralValue") { const jsonString = val?.value !== null && val?.value !== undefined ? stringify(val?.value) : undefined