From 077bc30afd52288c0f7a118e491b029c2fcd9865 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sat, 22 Aug 2026 18:17:40 +0200 Subject: [PATCH 1/4] feat: update sagittarius-graphql-types to experimental version 2780674370 --- package-lock.json | 10 +++++----- package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef03a12..547a993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@code0-tech/triangulum", "version": "0.1.0", "devDependencies": { - "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2", + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193", "@types/node": "^25.6.0", "@typescript/vfs": "^1.6.4", "lossless-json": "^4.3.1", @@ -19,7 +19,7 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2", + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193", "@typescript/vfs": "^1.6.4", "lossless-json": "^4.3.0", "ts-json-schema-generator": "^2.9.0", @@ -77,9 +77,9 @@ } }, "node_modules/@code0-tech/sagittarius-graphql-types": { - "version": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2", - "resolved": "https://registry.npmjs.org/@code0-tech/sagittarius-graphql-types/-/sagittarius-graphql-types-0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2.tgz", - "integrity": "sha512-Dg25HtCsSEhtQ40IccId73JpneIKZqGBLMA8pM3gLF5MZd5Z1wL+yNs/JH32BO22zZRokEBdKLgf7vgpGNK1/Q==", + "version": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193", + "resolved": "https://registry.npmjs.org/@code0-tech/sagittarius-graphql-types/-/sagittarius-graphql-types-0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193.tgz", + "integrity": "sha512-ME9xvxlywin40BYf8YGDkLY4DIE6CIsT9cBooj2jx0Q1KuItA9GFCKy0YM1bf0uuOJNR3qAg2s+2Ch8NfGwrHA==", "dev": true }, "node_modules/@jridgewell/sourcemap-codec": { diff --git a/package.json b/package.json index 1d61f1a..9a24b45 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "build": "vite build" }, "devDependencies": { - "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2", + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193", "@types/node": "^25.6.0", "@typescript/vfs": "^1.6.4", "lossless-json": "^4.3.1", @@ -46,7 +46,7 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2744343400-55133b9bb6062772c50f83d945a22a531a6e48e2", + "@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2780674370-053799e843870f117430b3b00f75ffec35767193", "@typescript/vfs": "^1.6.4", "lossless-json": "^4.3.0", "ts-json-schema-generator": "^2.9.0", From 5a4189b56531d9e2f46746b81272d13e5113a86d Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sat, 22 Aug 2026 18:17:53 +0200 Subject: [PATCH 2/4] feat: add inline reference handling and template literal support in TypeScript code generation --- src/utils.ts | 181 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 155 insertions(+), 26 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 4d82979..55168b4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,8 @@ import { NodeFunction, SubFlowValue, NodeParameter, - ReferenceValue, Maybe + ReferenceValue, Maybe, + InlineReferenceValue } from "@code0-tech/sagittarius-graphql-types"; import ts from "typescript"; import {createSystem, createVirtualTypeScriptEnvironment, VirtualTypeScriptEnvironment} from "@typescript/vfs" @@ -160,6 +161,75 @@ export function getSharedTypeDeclarations(dataTypes?: DataType[], genericType: s */ export const sanitizeId = (id: string) => id?.replace(/[^a-zA-Z0-9]/g, '_'); +/** + * Escapes text for safe inclusion inside a template literal, neutralising + * backslashes, backticks and any literal `${` that is not a resolved reference. + */ +const escapeTemplateText = (text: string): string => + text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); + +/** + * Emits a string leaf of a literal value. If the string contains `${signature}` + * tokens that match known inline references, it is emitted as a template literal + * with those tokens spliced as the referenced expressions; unknown tokens are + * preserved literally. Strings without any known reference are emitted as plain + * JSON string literals, identical to the prior behaviour. + */ +const emitStringLeaf = (value: string, refExpr: Map): string => { + // Whole-string standalone reference: the leaf is exactly `${signature}` with + // no surrounding text. Emit the raw referenced expression so its actual type + // is preserved (a NUMBER reference stays a NUMBER rather than being coerced + // to string). This matters for references sitting directly on an array + // element or object value, e.g. `[1, "${x}", 3]` or `{count: "${x}"}`. + const standalone = value.match(/^\$\{([^}]+)\}$/); + if (standalone && refExpr.has(standalone[1])) { + return refExpr.get(standalone[1])!; + } + + const tokenRe = /\$\{([^}]+)\}/g; + + let hasKnown = false; + for (const match of value.matchAll(tokenRe)) { + if (refExpr.has(match[1])) { + hasKnown = true; + break; + } + } + if (!hasKnown) return stringify(value) ?? '""'; + + let result = ""; + let last = 0; + for (const match of value.matchAll(tokenRe)) { + const [full, signature] = match; + const start = match.index!; + result += escapeTemplateText(value.slice(last, start)); + result += refExpr.has(signature) + ? "${" + refExpr.get(signature) + "}" + : escapeTemplateText(full); + last = start + full.length; + } + result += escapeTemplateText(value.slice(last)); + return "`" + result + "`"; +}; + +/** + * Recursively emits a literal JSON value as a TypeScript expression, splicing + * inline references into string leaves. Non-string primitives keep their + * lossless-JSON representation so number precision is preserved. + */ +const emitLiteralNode = (value: unknown, refExpr: Map): string => { + if (typeof value === "string") return emitStringLeaf(value, refExpr); + if (Array.isArray(value)) { + return `[${value.map(item => emitLiteralNode(item, refExpr)).join(", ")}]`; + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record) + .map(([key, val]) => `${stringify(key)}: ${emitLiteralNode(val, refExpr)}`); + return `{${entries.join(", ")}}`; + } + return stringify(value) ?? "undefined"; +}; + /** * Generates TypeScript source code for a flow, suitable for validation and type inference. */ @@ -174,6 +244,87 @@ export function generateFlowSourceCode( const funcMap = new Map(functions?.map(f => [f.identifier, f])); const visited = new Set(); + // Emits the TypeScript expression for a reference value (without the leading + // `@pos` marker). A reference may be nullable (`string | null`) or reach + // through optional properties (an optional chain like `node_X?.text`). When + // `assertNonNullReferences` is set (inference/schema), the nullish part is + // waived with a `!` so the base type flows cleanly. During validation it is + // left in place so a possibly-null reference feeding a non-null parameter + // surfaces a diagnostic — which is then downgraded to a warning rather than + // being silently erased. Base type mismatches still fail validation in both + // modes. Reused both for top-level parameters and for inline references + // spliced into literal values. + const renderReference = (ref: ReferenceValue): string => { + let refCode = typeof ref.inputIndex === "number" + ? `p_${sanitizeId(ref.nodeFunctionId ?? "undefined")}_${ref.parameterIndex}[${ref.inputIndex}]` + : ref.nodeFunctionId ? `node_${sanitizeId(ref.nodeFunctionId)}` : `flow_${sanitizeId(flow?.id ?? "undefined")}`; + ref.referencePath?.forEach(pathObj => { + refCode += `?.${pathObj.path}`; + }); + const nonNull = assertNonNullReferences ? "!" : ""; + return `(${refCode})${nonNull}`; + }; + + // Emits the TypeScript expression for a sub-flow value (without the leading + // `@pos` marker). A sub-flow that directly maps an existing function emits + // that function reference so its signature drives the sub-flow's I/O; + // otherwise a lambda wrapping the sub-tree is generated. The lambda parameter + // name deliberately stays `p_${id}_${index}` (the parent node/param slot): + // references to the sub-flow's own inputs resolve through that exact name + // (see `renderReference`). Multiple sub-flows spliced into the same literal + // (e.g. an array) are sibling expressions in separate function scopes, so an + // identical parameter name across them does not collide. + const renderSubFlow = ( + wrapper: SubFlowValue, + id: NodeFunction['id'] | FunctionDefinition['identifier'], + index: number, + indent: string + ): string => { + if (!wrapper.startingNodeId && wrapper.functionDefinition?.identifier) { + return `fn_${wrapper.functionDefinition.identifier.replace(/::/g, '_')}`; + } + const lambdaArgName = `p_${sanitizeId(id as string)}_${index}`; + const subTreeCode = generateNodeCode(wrapper.startingNodeId || wrapper.functionDefinition?.id!, indent + " "); + return `(...${lambdaArgName}) => {\n${subTreeCode}${indent}}`; + }; + + // Emits the TypeScript expression for a literal value. When the literal + // carries inline references (addressable via `${signature}` inside its + // string leaves), each occurrence is spliced with the generated expression + // of the referenced value so the value is type-checked. A string leaf that + // consists solely of a single `${signature}` preserves the referenced type + // verbatim (a NUMBER reference stays a NUMBER); a leaf with surrounding text + // or multiple tokens becomes a template literal (a string). References are + // resolved recursively through nested literals, arrays and objects. Without + // references, the original lossless-JSON stringification is preserved. + const renderLiteral = ( + value: unknown, + references: Maybe | undefined, + ctx: { id: NodeFunction['id'] | FunctionDefinition['identifier']; index: number; indent: string } + ): string | undefined => { + if (value === null || value === undefined) return undefined; + + const refExpr = new Map(); + for (const r of references ?? []) { + if (!r?.signature) continue; + const inner = r.value; + if (inner?.__typename === "ReferenceValue") { + refExpr.set(r.signature, renderReference(inner as ReferenceValue)); + } else if (inner?.__typename === "LiteralValue") { + const nested = renderLiteral(inner.value, inner.references, ctx); + if (nested !== undefined) refExpr.set(r.signature, nested); + } else if (inner?.__typename === "SubFlowValue") { + refExpr.set(r.signature, renderSubFlow(inner as SubFlowValue, ctx.id, ctx.index, ctx.indent)); + } + } + + // No resolvable inline references: keep the exact prior behaviour so + // existing generated code (and number precision) is unchanged. + if (refExpr.size === 0) return stringify(value); + + return emitLiteralNode(value, refExpr); + }; + const generateNodeCode = (id: NodeFunction['id'] | FunctionDefinition['identifier'], indent: string = ""): string => { const node = nodes.find(n => n?.id === id); if (!node || !node.functionDefinition) return ""; @@ -187,41 +338,19 @@ export function generateFlowSourceCode( const val = p.value; if (!val) return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`; if (val.__typename === "ReferenceValue") { - const ref = val as ReferenceValue; - let refCode = typeof ref.inputIndex === "number" - ? `p_${sanitizeId(ref.nodeFunctionId ?? "undefined")}_${ref.parameterIndex}[${ref.inputIndex}]` - : ref.nodeFunctionId ? `node_${sanitizeId(ref.nodeFunctionId)}` : `flow_${sanitizeId(flow?.id ?? "undefined")}`; - ref.referencePath?.forEach(pathObj => { - refCode += `?.${pathObj.path}`; - }); - // A reference may be nullable (`string | null`) or reach through optional - // properties (an optional chain like `node_X?.text`). When `assertNonNullReferences` - // is set (inference/schema), the nullish part is waived with a `!` so the base - // type flows cleanly. During validation it is left in place so a possibly-null - // reference feeding a non-null parameter surfaces a diagnostic — which is then - // downgraded to a warning rather than being silently erased. Base type - // mismatches still fail validation in both modes. - const nonNull = assertNonNullReferences ? "!" : ""; - return `/* @pos ${id} ${index} */ (${refCode})${nonNull}`; + return `/* @pos ${id} ${index} */ ${renderReference(val as ReferenceValue)}`; } if (val.__typename === "LiteralValue") { - const jsonString = val?.value !== null && val?.value !== undefined ? stringify(val?.value) : undefined + const jsonString = renderLiteral(val.value, val.references, {id, index, indent}); return `/* @pos ${id} ${index} */ ${jsonString}`; } if (val.__typename === "SubFlowValue") { - const wrapper = val as SubFlowValue; // Direct mapping: the sub-flow *is* an existing function, with no // node tree of its own (no startingNodeId). Emit the function // reference itself as the value so its own signature drives the // sub-flow's I/O — e.g. mapping `std::math::add` yields // `(a, b) => NUMBER` rather than an empty `(...p) => {}` lambda. - if (!wrapper.startingNodeId && wrapper.functionDefinition?.identifier) { - const funcName = `fn_${wrapper.functionDefinition.identifier.replace(/::/g, '_')}`; - return `/* @pos ${id} ${index} */ ${funcName}`; - } - const lambdaArgName = `p_${sanitizeId(id as string)}_${index}`; - const subTreeCode = generateNodeCode(wrapper.startingNodeId || wrapper.functionDefinition?.id!, indent + " "); - return `/* @pos ${id} ${index} */ (...${lambdaArgName}) => {\n${subTreeCode}${indent}}`; + return `/* @pos ${id} ${index} */ ${renderSubFlow(val as SubFlowValue, id, index, indent)}`; } return isForInference ? `/* @pos ${id} ${index} */ {}` : `/* @pos ${id} ${index} */ undefined`; }); From 8c8337cac6097ed2bb06f3a039c4d269314dd0e9 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sat, 22 Aug 2026 18:17:58 +0200 Subject: [PATCH 3/4] feat: add tests for inline reference handling in literal values --- test/flowSchemas.test.ts | 55 +++++- test/flowValidation.test.ts | 323 +++++++++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 2 deletions(-) diff --git a/test/flowSchemas.test.ts b/test/flowSchemas.test.ts index e921870..8c0dc36 100644 --- a/test/flowSchemas.test.ts +++ b/test/flowSchemas.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from "vitest"; -import type {Flow, NodeFunction, NodeParameterValue} from "@code0-tech/sagittarius-graphql-types"; +import type {Flow, FunctionDefinition, NodeFunction, NodeParameterValue} from "@code0-tech/sagittarius-graphql-types"; import {getFlowSchemas} from "../src/server"; import type {JsonSchema, SchematizedSubFlowValue} from "../src/server"; import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data"; @@ -188,6 +188,59 @@ describe("getFlowSchemas", () => { expect(consumer.startingNodeId).toBe(BODY_NODE_ID); }); + it("resolves an inline reference inside a for_each list literal into the item schema", () => { + // A source node returns a NUMBER; the for_each list literal is `["${n}"]` + // where the standalone `${n}` reference preserves that NUMBER type. Schema + // generation must therefore infer the sub-flow item as a number — proving + // inline references flow through getFlowSchemas, not just validation. + const SRC_ID = "gid://sagittarius/NodeFunction/9" as NodeId; + const SRC_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9301", + identifier: "custom::src::num", + signature: "(): NUMBER", + }; + + const flow: Flow = { + id: FLOW_ID, + startingNodeId: SRC_ID, + nodes: { + nodes: [ + { + id: SRC_ID, + functionDefinition: {identifier: "custom::src::num"}, + nextNodeId: LIST_NODE_ID, + parameters: {nodes: []}, + }, + node(LIST_NODE_ID, "std::list::for_each", [ + { + __typename: "LiteralValue", + value: ["${n}"], + references: [ + { + __typename: "InlineReferenceValue", + signature: "n", + value: {__typename: "ReferenceValue", nodeFunctionId: SRC_ID}, + }, + ], + } as NodeParameterValue, + subFlowStartingAt(BODY_NODE_ID), + ]), + node(BODY_NODE_ID, "std::number::add", [ + subFlowInputReference(LIST_NODE_ID, 1), + literal(10), + ]), + ], + }, + }; + + const result = getFlowSchemas(flow, [...FUNCTION_SIGNATURES, SRC_FN], DATA_TYPES); + const consumer = subFlowValueOf(result, LIST_NODE_ID, 1); + + // T resolves to NUMBER, so the callback item is a number schema. + expect((consumer.inputSchema?.properties?.item as JsonSchema)).toEqual({type: "number"}); + expect(consumer.__typename).toBe("SubFlowValue"); + }); + it("enriches a filter predicate sub-flow with a boolean output", () => { // filter over [1, 2, 3], the sub-flow body converts each item to a boolean. const flow = flowWithNodes([ diff --git a/test/flowValidation.test.ts b/test/flowValidation.test.ts index 52f935f..aaa3483 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 {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen +import {DataType, Flow, FunctionDefinition, InlineReferenceValue} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen // @ts-ignore import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data"; @@ -1564,4 +1564,325 @@ describe('getFlowValidation - Integrationstest', () => { }); + describe('inline references inside literal values', () => { + // custom::src(): {n: NUMBER, t: TEXT} — a single source node exposing both a + // NUMBER (`n`) and a TEXT (`t`) via reference paths, so inline references can + // target either type. + const SRC: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9201", + identifier: "custom::src", + signature: "(): {n: NUMBER, t: TEXT}", + }; + // Sinks with concrete parameter types, each returning void. + const SINK_TEXT: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9202", + identifier: "custom::sink::text", + signature: "(value: TEXT): void", + }; + const SINK_NUMBER: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9203", + identifier: "custom::sink::number", + signature: "(value: NUMBER): void", + }; + const SINK_NUMBER_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9204", + identifier: "custom::sink::numberList", + signature: "(value: LIST): void", + }; + const SINK_OBJ: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9205", + identifier: "custom::sink::obj", + signature: "(value: {count: NUMBER, label: TEXT}): void", + }; + const SINK_COMBO: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9206", + identifier: "custom::sink::combo", + signature: "(value: {label: TEXT, counts: LIST}): void", + }; + const SINK_FN: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9207", + identifier: "custom::sink::fn", + signature: "(value: (value: TEXT) => TEXT): void", + }; + const SINK_FN_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9208", + identifier: "custom::sink::fnList", + signature: "(value: LIST<(value: TEXT) => TEXT>): void", + }; + const SINK_LAMBDA_LIST: FunctionDefinition = { + id: "gid://sagittarius/FunctionDefinition/9209", + identifier: "custom::sink::lambdaList", + signature: "(value: LIST<() => NUMBER>): void", + }; + + const CUSTOM_FUNCTIONS = [ + ...FUNCTION_SIGNATURES, + SRC, SINK_TEXT, SINK_NUMBER, SINK_NUMBER_LIST, SINK_OBJ, SINK_COMBO, SINK_FN, SINK_FN_LIST, SINK_LAMBDA_LIST, + ]; + + const NODE1 = "gid://sagittarius/NodeFunction/1"; + + // A reference to `custom::src`'s NUMBER (`n`) or TEXT (`t`) property. + const refTo = (path: "n" | "t"): any => ({ + __typename: "ReferenceValue", + nodeFunctionId: NODE1, + referencePath: [{path}], + }); + const inlineRef = (signature: string, value: any): InlineReferenceValue => ({ + __typename: "InlineReferenceValue", + signature, + value, + }); + + // node1 = custom::src, node2 = `sink` consuming a single literal argument + // that carries the given inline references. + const buildFlow = (sink: string, literal: unknown, references: InlineReferenceValue[]): Flow => ({ + startingNodeId: NODE1, + signature: "(): void", + nodes: { + nodes: [ + { + id: NODE1, + functionDefinition: {identifier: "custom::src"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: []}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: sink}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: literal, references}}, + ], + }, + }, + ], + }, + }); + + const validate = (sink: string, literal: unknown, references: InlineReferenceValue[]) => + getFlowValidation(buildFlow(sink, literal, references), CUSTOM_FUNCTIONS, DATA_TYPES); + + const expectValid = (result: ReturnType) => { + expect(result.isValid).toBe(true); + expect(result.diagnostics.every(d => d.severity !== "error")).toBe(true); + }; + const expectError = (result: ReturnType) => { + expect(result.isValid).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + nodeId: "gid://sagittarius/NodeFunction/2", + parameterIndex: 0, + severity: "error", + }), + ])); + }; + + // --- LiteralValue with a ReferenceValue --- + + it('standalone `${n}` preserves the NUMBER type into a NUMBER parameter', () => { + expectValid(validate("custom::sink::number", "${n}", [inlineRef("n", refTo("n"))])); + }); + + it('standalone `${n}` stays a NUMBER and is rejected by a TEXT parameter', () => { + // Regression guard: a standalone reference must NOT be coerced to string. + expectError(validate("custom::sink::text", "${n}", [inlineRef("n", refTo("n"))])); + }); + + it('interpolated `"Hi ${t}"` becomes a string and satisfies TEXT', () => { + expectValid(validate("custom::sink::text", "Hi ${t}", [inlineRef("t", refTo("t"))])); + }); + + it('interpolating a NUMBER into surrounding text yields a (valid) TEXT string', () => { + expectValid(validate("custom::sink::text", "Count: ${n}", [inlineRef("n", refTo("n"))])); + }); + + it('interpolated string is rejected by a NUMBER parameter', () => { + expectError(validate("custom::sink::number", "Count: ${n}", [inlineRef("n", refTo("n"))])); + }); + + // --- LiteralValue arrays containing references --- + + it('array with a standalone NUMBER reference is a valid LIST', () => { + expectValid(validate("custom::sink::numberList", [1, "${n}", 3], [inlineRef("n", refTo("n"))])); + }); + + it('array element referencing TEXT breaks LIST', () => { + expectError(validate("custom::sink::numberList", [1, "${t}"], [inlineRef("t", refTo("t"))])); + }); + + // --- LiteralValue objects with a reference on a key --- + + it('object with references on keys matches the expected object type', () => { + expectValid(validate( + "custom::sink::obj", + {count: "${n}", label: "${t}"}, + [inlineRef("n", refTo("n")), inlineRef("t", refTo("t"))], + )); + }); + + it('object with swapped reference types is rejected', () => { + expectError(validate( + "custom::sink::obj", + {count: "${t}", label: "${n}"}, + [inlineRef("t", refTo("t")), inlineRef("n", refTo("n"))], + )); + }); + + // --- LiteralValue nested in LiteralValue --- + + it('splices a nested literal (with its own reference) into surrounding text', () => { + // outer `"greeting: ${mid}"` where mid resolves to the literal + // `"hi ${deep}"` which itself references TEXT — a string throughout. + expectValid(validate( + "custom::sink::text", + "greeting: ${mid}", + [inlineRef("mid", { + __typename: "LiteralValue", + value: "hi ${deep}", + references: [inlineRef("deep", refTo("t"))], + })], + )); + }); + + it('standalone nested numeric literal preserves NUMBER', () => { + // `"${mid}"` resolves to the plain numeric literal 42 — kept as a number. + expectValid(validate( + "custom::sink::number", + "${mid}", + [inlineRef("mid", {__typename: "LiteralValue", value: 42})], + )); + }); + + // --- LiteralValue with a SubFlowValue --- + + it('splices a direct-mapping sub-flow reference as a function value', () => { + // The inline reference is a sub-flow directly mapping std::text::capitalize + // `(value: TEXT): TEXT`; standalone `${fn}` yields that function reference, + // which satisfies the `(value: TEXT) => TEXT` parameter. + expectValid(validate( + "custom::sink::fn", + "${fn}", + [inlineRef("fn", { + __typename: "SubFlowValue", + functionDefinition: {identifier: "std::text::capitalize"}, + })], + )); + }); + + it('splices an array of sub-flow references as a LIST of functions', () => { + // Two distinct sub-flows (capitalize, lowercase), each `(value: TEXT): TEXT`, + // sit in an array — the standalone `${fnN}` elements become function + // references, forming a valid LIST<(value: TEXT) => TEXT>. + expectValid(validate( + "custom::sink::fnList", + ["${fn1}", "${fn2}"], + [ + inlineRef("fn1", { + __typename: "SubFlowValue", + functionDefinition: {identifier: "std::text::capitalize"}, + }), + inlineRef("fn2", { + __typename: "SubFlowValue", + functionDefinition: {identifier: "std::text::lowercase"}, + }), + ], + )); + }); + + it('generates distinct lambdas for an array of sub-tree sub-flows', () => { + // Two sub-flows with their own node trees (each returns a NUMBER literal) + // are spliced into an array. Both lambdas share the `p_..._0` parameter + // name but live in separate scopes, so the LIST<() => NUMBER> is valid. + const flow: Flow = { + startingNodeId: NODE1, + signature: "(): void", + nodes: { + nodes: [ + { + id: NODE1, + functionDefinition: {identifier: "custom::src"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: []}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "custom::sink::lambdaList"}, + parameters: { + nodes: [ + { + value: { + __typename: "LiteralValue", + value: ["${s1}", "${s2}"], + references: [ + inlineRef("s1", { + __typename: "SubFlowValue", + startingNodeId: "gid://sagittarius/NodeFunction/3", + }), + inlineRef("s2", { + __typename: "SubFlowValue", + startingNodeId: "gid://sagittarius/NodeFunction/4", + }), + ], + }, + }, + ], + }, + }, + { + id: "gid://sagittarius/NodeFunction/3", + functionDefinition: {identifier: "std::control::return"}, + parameters: {nodes: [{value: {__typename: "LiteralValue", value: 1}}]}, + }, + { + id: "gid://sagittarius/NodeFunction/4", + functionDefinition: {identifier: "std::control::return"}, + parameters: {nodes: [{value: {__typename: "LiteralValue", value: 2}}]}, + }, + ], + }, + }; + + expectValid(getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES)); + }); + + it('rejects an array of sub-flows against a LIST parameter', () => { + // Functions are not numbers: the array cannot satisfy LIST. + expectError(validate( + "custom::sink::numberList", + ["${fn1}"], + [inlineRef("fn1", { + __typename: "SubFlowValue", + functionDefinition: {identifier: "std::text::capitalize"}, + })], + )); + }); + + // --- Combination of everything --- + + it('validates an object mixing interpolated text and an array of references', () => { + expectValid(validate( + "custom::sink::combo", + {label: "Hi ${t}", counts: [1, "${n}", 3]}, + [inlineRef("t", refTo("t")), inlineRef("n", refTo("n"))], + )); + }); + + it('flags a type error deep inside a combined object/array literal', () => { + // The array element references TEXT where LIST is required. + expectError(validate( + "custom::sink::combo", + {label: "Hi ${t}", counts: [1, "${t}"]}, + [inlineRef("t", refTo("t"))], + )); + }); + + // --- Unknown / no references (regression) --- + + it('leaves an unknown `${token}` as a literal string', () => { + // No matching inline reference: the value stays a plain string literal. + expectValid(validate("custom::sink::text", "${unknown}", [])); + }); + }); + }); \ No newline at end of file From 3a6a563978433c4f8333d132531a4856e5682b9a Mon Sep 17 00:00:00 2001 From: nicosammito Date: Sat, 22 Aug 2026 18:19:18 +0200 Subject: [PATCH 4/4] feat: update entry resolution in vite.config.ts to use import.meta.dirname --- vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 9cd4875..950a414 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,8 +8,8 @@ export default defineConfig({ // "index" is browser-safe; "schemas" is server-only because it pulls in // ts-json-schema-generator (and transitively `fs`). entry: { - index: resolve(__dirname, 'src/index.ts'), - server: resolve(__dirname, 'src/server.ts') + index: resolve(import.meta.dirname, 'src/index.ts'), + server: resolve(import.meta.dirname, 'src/server.ts') }, name: 'triangulum', // With "type": "module", .js is ESM and .cjs is CommonJS — the CJS build