Skip to content

Commit 7052e2d

Browse files
authored
Merge pull request #141 from code0-tech/feat/#140
Parameter is falsely blocked on missing flag value extraction
2 parents 496b768 + 3b4ddd3 commit 7052e2d

3 files changed

Lines changed: 473 additions & 5 deletions

File tree

src/schema/getSignatureSchema.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,6 @@ export const getSignatureSchema = (
109109
: type
110110
)
111111

112-
// Identify parameter dependencies based on type parameters
113-
const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes)
114-
115112
// Track which parameter slots actually carry a user-supplied value. The merge
116113
// uses this as a last-resort signal: if the function- and node-side schemas
117114
// both came out generic but the user did set something, the lift falls back
@@ -120,6 +117,9 @@ export const getSignatureSchema = (
120117
(p) => p?.value != null
121118
)
122119

120+
// Identify parameter dependencies based on type parameters
121+
const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes, valueProvidedByIndex)
122+
123123
// Generate schema for each parameter
124124
const parameters = generateNodeSchemas(
125125
checker,
@@ -280,15 +280,19 @@ const extractFunctionParameterTypes = (
280280
/**
281281
* Identifies parameter dependencies based on shared type parameters.
282282
* Determines which parameters depend on type parameters declared in other parameters.
283-
* If an argument is explicitly provided (not null/undefined), it is not blocked.
283+
* A dependency is cleared once either the depended-on parameter carries a value
284+
* (so the shared type parameter is pinned by the user's choice) or the dependent
285+
* parameter's own argument already resolves to a concrete type.
284286
*
285287
* @param funktion - The function declaration to analyze
286288
* @param nodeParameterTypes
289+
* @param valueProvidedByIndex - Whether each parameter slot carries a user-supplied value
287290
* @returns Array of ParameterDependency objects
288291
*/
289292
const getParameterDependencies = (
290293
funktion: ts.FunctionDeclaration,
291-
nodeParameterTypes: ts.Type[] | undefined
294+
nodeParameterTypes: ts.Type[] | undefined,
295+
valueProvidedByIndex: boolean[] = [],
292296
): ParameterDependency[] => {
293297
const typeParamNames = funktion.typeParameters?.map((tp) => tp.name.getText()) || []
294298
const usage: Record<string, number[]> = {}
@@ -347,6 +351,11 @@ const getParameterDependencies = (
347351

348352
// Filter heraus, was durch echte Werte (nicht null/undefined) bereits aufgelöst ist
349353
return rawDependencies.filter((dep) => {
354+
// Providing the depended-on parameter pins the shared type parameter, so
355+
// the dependent is unblocked — even when the value carries no element type
356+
// to infer from (e.g. an empty list literal `[]`).
357+
if (valueProvidedByIndex[dep.dependsOnIndex]) return false
358+
350359
const resolvedType = nodeParameterTypes[dep.parameterIndex]
351360

352361
// Falls aus irgendeinem Grund kein Typ ermittelt werden konnte -> blocked lassen

test/schema/circular.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {describe, expect, it} from "vitest";
2+
import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types";
3+
import {getSignatureSchema} from "../../src";
4+
import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data";
5+
6+
// A single two-parameter node calling `identifier`, probed at that node. Returns
7+
// the input kind and blockedBy list for each of the two parameters.
8+
const probeTwoParam = (
9+
identifier: string,
10+
extraDataTypes: DataType[],
11+
extraFunctions: FunctionDefinition[],
12+
v0: any,
13+
v1: any,
14+
) => {
15+
const flow: Flow = {
16+
id: "gid://sagittarius/Flow/1",
17+
startingNodeId: "gid://sagittarius/NodeFunction/1",
18+
signature: "(): void",
19+
nodes: {
20+
nodes: [
21+
{
22+
id: "gid://sagittarius/NodeFunction/1",
23+
functionDefinition: {identifier},
24+
parameters: {nodes: [{value: v0}, {value: v1}]},
25+
},
26+
],
27+
},
28+
};
29+
return getSignatureSchema(
30+
flow,
31+
[...DATA_TYPES, ...extraDataTypes],
32+
[...FUNCTION_SIGNATURES, ...extraFunctions],
33+
"gid://sagittarius/NodeFunction/1",
34+
).parameters.map((p) => ({input: p.schema.input, blockedBy: p.blockedBy}));
35+
};
36+
37+
const lit = (value: any) => ({__typename: "LiteralValue", value});
38+
39+
describe("Schema — circular / conditional parameter dependencies", () => {
40+
41+
// ─────────────────────────────────────────────────────────────────────────
42+
// Scenario A: distinct generics, each used in exactly ONE parameter.
43+
// <L, R>(left: CIRC_LEFT<R>, right: CIRC_RIGHT<L>)
44+
// The two parameters mutually depend on each other, but because no single
45+
// generic is shared across both parameters, the shared-generic detector never
46+
// records a dependency. Neither is ever blocked.
47+
// ─────────────────────────────────────────────────────────────────────────
48+
describe("distinct generics, one per parameter (dependency invisible)", () => {
49+
const dataTypes: DataType[] = [
50+
{identifier: "CIRC_LEFT", genericKeys: ["T"], type: "T extends 'json' ? OBJECT<{}> : string"},
51+
{identifier: "CIRC_RIGHT", genericKeys: ["T"], type: "T extends string ? 'json' : 'text'"},
52+
];
53+
const fn: FunctionDefinition = {
54+
id: "gid://sagittarius/FunctionDefinition/9100",
55+
identifier: "custom::test::circular_distinct",
56+
signature: "<L, R>(left: CIRC_LEFT<R>, right: CIRC_RIGHT<L>): void",
57+
};
58+
const probe = (v0: any, v1: any) =>
59+
probeTwoParam("custom::test::circular_distinct", dataTypes, [fn], v0, v1);
60+
61+
it("never blocks either parameter, for any combination of values", () => {
62+
for (const [l, r] of [
63+
[null, null],
64+
[lit({}), null],
65+
[null, lit("json")],
66+
[lit({}), lit("json")],
67+
] as const) {
68+
const [left, right] = probe(l, r);
69+
expect(left.blockedBy).toEqual([]);
70+
expect(right.blockedBy).toEqual([]);
71+
}
72+
});
73+
74+
it("resolves each conditional to its unbound-generic (false) branch → both text", () => {
75+
// CIRC_LEFT<R> → R extends 'json' ? OBJECT<{}> : string → string → "text"
76+
// CIRC_RIGHT<L> → L extends string ? 'json' : 'text' → 'text' → "text"
77+
for (const [l, r] of [
78+
[null, null],
79+
[lit({}), lit("json")],
80+
] as const) {
81+
const [left, right] = probe(l, r);
82+
expect(left.input).toBe("text");
83+
expect(right.input).toBe("text");
84+
}
85+
});
86+
});
87+
88+
// ─────────────────────────────────────────────────────────────────────────
89+
// Scenario B: both generics shared across both parameters, conditional falls
90+
// back to a CONCRETE type.
91+
// <L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>)
92+
// A dependency IS detected (right depends on left), but the second check drops
93+
// it because `right`'s conditional always lands on a concrete branch — so it
94+
// never looks unresolved. Nothing is blocked.
95+
// ─────────────────────────────────────────────────────────────────────────
96+
describe("shared generics, conditional with concrete fallback (never blocked)", () => {
97+
const dataTypes: DataType[] = [
98+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R extends string ? string : number"},
99+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L extends string ? string : number"},
100+
];
101+
const fn: FunctionDefinition = {
102+
id: "gid://sagittarius/FunctionDefinition/9101",
103+
identifier: "custom::test::circular_concrete",
104+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
105+
};
106+
const probe = (v0: any, v1: any) =>
107+
probeTwoParam("custom::test::circular_concrete", dataTypes, [fn], v0, v1);
108+
109+
it("never blocks either parameter, because the dependent always has a concrete type", () => {
110+
for (const [l, r] of [
111+
[null, null],
112+
[lit({}), null],
113+
[null, lit({})],
114+
[lit({}), lit({})],
115+
] as const) {
116+
const [left, right] = probe(l, r);
117+
expect(left.blockedBy).toEqual([]);
118+
expect(right.blockedBy).toEqual([]);
119+
// The unbound conditionals collapse to their innermost `number` branch.
120+
expect(left.input).toBe("number");
121+
expect(right.input).toBe("number");
122+
}
123+
});
124+
});
125+
126+
// ─────────────────────────────────────────────────────────────────────────
127+
// Scenario C: both generics shared across both parameters, conditional falls
128+
// back to a BARE generic (so nothing resolves until the other side binds it).
129+
// <L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>)
130+
// Now the dependency both (1) is detected and (2) survives the concrete-type
131+
// check, so `right` is genuinely blocked by `left`.
132+
// ─────────────────────────────────────────────────────────────────────────
133+
describe("shared generics, bare-generic fallback (right blocked by left)", () => {
134+
const dataTypes: DataType[] = [
135+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"},
136+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : L"},
137+
];
138+
const fn: FunctionDefinition = {
139+
id: "gid://sagittarius/FunctionDefinition/9102",
140+
identifier: "custom::test::circular_bare",
141+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
142+
};
143+
const probe = (v0: any, v1: any) =>
144+
probeTwoParam("custom::test::circular_bare", dataTypes, [fn], v0, v1);
145+
146+
it("blocks right by left while nothing is set; left (index 0) is never blocked", () => {
147+
const [left, right] = probe(null, null);
148+
// left is the FIRST user of the shared generics → treated as the source,
149+
// never blocked.
150+
expect(left.blockedBy).toEqual([]);
151+
expect(left.input).toBe("generic");
152+
// right depends on left. Both L and R produce the same dependency, so the
153+
// index appears twice (the mechanism does not de-duplicate).
154+
expect(right.blockedBy).toEqual([0, 0]);
155+
expect(right.input).toBe("generic");
156+
});
157+
158+
it("unblocks right once left is provided", () => {
159+
const [left, right] = probe(lit("json"), null);
160+
expect(left.blockedBy).toEqual([]);
161+
expect(right.blockedBy).toEqual([]);
162+
});
163+
164+
it("never blocks left, whichever side is set", () => {
165+
for (const [l, r] of [
166+
[null, null],
167+
[lit("json"), null],
168+
[null, lit("json")],
169+
[lit("json"), lit("json")],
170+
] as const) {
171+
expect(probe(l, r)[0].blockedBy).toEqual([]);
172+
}
173+
});
174+
});
175+
176+
// ─────────────────────────────────────────────────────────────────────────
177+
// Scenario D: asymmetric — the dependent's generic sits only in the CHECK
178+
// position of its own conditional, so it can never be inferred from the
179+
// dependent's own value.
180+
// CIRC_LEFT<L,R> = L extends 'json' ? OBJECT<{}> : R (R inferable here)
181+
// CIRC_RIGHT<L,R> = R extends 'json' ? OBJECT<{}> : undefined (R only in check pos.)
182+
// `right` can only be unblocked by `left` (which binds R); filling `right`'s
183+
// own value does NOT unblock it.
184+
// ─────────────────────────────────────────────────────────────────────────
185+
describe("check-position generic (right unblockable only by left)", () => {
186+
const dataTypes: DataType[] = [
187+
{identifier: "CIRC_LEFT", genericKeys: ["L", "R"], type: "L extends 'json' ? OBJECT<{}> : R"},
188+
{identifier: "CIRC_RIGHT", genericKeys: ["L", "R"], type: "R extends 'json' ? OBJECT<{}> : undefined"},
189+
];
190+
const fn: FunctionDefinition = {
191+
id: "gid://sagittarius/FunctionDefinition/9103",
192+
identifier: "custom::test::circular_check",
193+
signature: "<L, R>(left: CIRC_LEFT<L, R>, right: CIRC_RIGHT<L, R>): void",
194+
};
195+
const probe = (v0: any, v1: any) =>
196+
probeTwoParam("custom::test::circular_check", dataTypes, [fn], v0, v1);
197+
198+
it("blocks right while left is empty", () => {
199+
const [left, right] = probe(null, null);
200+
expect(left.blockedBy).toEqual([]);
201+
expect(right.blockedBy).toEqual([0, 0]);
202+
});
203+
204+
it("does NOT unblock right when only right's own value is set", () => {
205+
// right's generic R is only in the check position of its conditional, so
206+
// TypeScript cannot infer R from right's value → right stays blocked on
207+
// left. The input shows "data" merely because a value was supplied (the
208+
// schema falls back to an open object for the UI), even though the block
209+
// remains.
210+
const [left, right] = probe(null, lit("json"));
211+
expect(left.blockedBy).toEqual([]);
212+
expect(right.blockedBy).toEqual([0, 0]);
213+
expect(right.input).toBe("data");
214+
});
215+
216+
it("unblocks right when left is set (the only thing that binds R)", () => {
217+
const [leftOnly] = [probe(lit("json"), null)];
218+
expect(leftOnly[1].blockedBy).toEqual([]);
219+
220+
const bothSet = probe(lit("json"), lit("json"));
221+
expect(bothSet[1].blockedBy).toEqual([]);
222+
});
223+
224+
it("never blocks left", () => {
225+
for (const [l, r] of [
226+
[null, null],
227+
[lit("json"), null],
228+
[null, lit("json")],
229+
[lit("json"), lit("json")],
230+
] as const) {
231+
expect(probe(l, r)[0].blockedBy).toEqual([]);
232+
}
233+
});
234+
});
235+
});

0 commit comments

Comments
 (0)