@brika/type-system 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { T } from '../descriptor';
3
+ import { isCompatible } from '../compatibility';
4
+
5
+ describe('isCompatible', () => {
6
+ // ─────────────────────────────────────────────────────────────────────────
7
+ // Wildcards
8
+ // ─────────────────────────────────────────────────────────────────────────
9
+
10
+ describe('wildcards', () => {
11
+ it('any input accepts anything', () => {
12
+ expect(isCompatible(T.string, T.any)).toBe(true);
13
+ expect(isCompatible(T.number, T.any)).toBe(true);
14
+ expect(isCompatible(T.obj({ x: T.number }), T.any)).toBe(true);
15
+ });
16
+
17
+ it('unknown input accepts anything', () => {
18
+ expect(isCompatible(T.string, T.unknown)).toBe(true);
19
+ expect(isCompatible(T.number, T.unknown)).toBe(true);
20
+ });
21
+
22
+ it('any output is accepted by anything', () => {
23
+ expect(isCompatible(T.any, T.string)).toBe(true);
24
+ expect(isCompatible(T.any, T.number)).toBe(true);
25
+ });
26
+
27
+ it('unknown output is accepted by anything', () => {
28
+ expect(isCompatible(T.unknown, T.string)).toBe(true);
29
+ expect(isCompatible(T.unknown, T.number)).toBe(true);
30
+ });
31
+
32
+ it('generic input accepts anything', () => {
33
+ expect(isCompatible(T.string, T.generic())).toBe(true);
34
+ expect(isCompatible(T.number, T.generic('T'))).toBe(true);
35
+ });
36
+
37
+ it('generic output is accepted by anything', () => {
38
+ expect(isCompatible(T.generic(), T.string)).toBe(true);
39
+ expect(isCompatible(T.generic('T'), T.number)).toBe(true);
40
+ });
41
+ });
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────
44
+ // Primitives
45
+ // ─────────────────────────────────────────────────────────────────────────
46
+
47
+ describe('primitives', () => {
48
+ it('same primitive types are compatible', () => {
49
+ expect(isCompatible(T.string, T.string)).toBe(true);
50
+ expect(isCompatible(T.number, T.number)).toBe(true);
51
+ expect(isCompatible(T.boolean, T.boolean)).toBe(true);
52
+ expect(isCompatible(T.null, T.null)).toBe(true);
53
+ });
54
+
55
+ it('different primitive types are incompatible', () => {
56
+ expect(isCompatible(T.string, T.number)).toBe(false);
57
+ expect(isCompatible(T.boolean, T.number)).toBe(false);
58
+ expect(isCompatible(T.string, T.boolean)).toBe(false);
59
+ });
60
+
61
+ it('number widens to string', () => {
62
+ expect(isCompatible(T.number, T.string)).toBe(true);
63
+ });
64
+
65
+ it('boolean widens to string', () => {
66
+ expect(isCompatible(T.boolean, T.string)).toBe(true);
67
+ });
68
+
69
+ it('string does NOT widen to number', () => {
70
+ expect(isCompatible(T.string, T.number)).toBe(false);
71
+ });
72
+ });
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+ // Literals
76
+ // ─────────────────────────────────────────────────────────────────────────
77
+
78
+ describe('literals', () => {
79
+ it('same literal values match', () => {
80
+ expect(isCompatible(T.literal('hello'), T.literal('hello'))).toBe(true);
81
+ expect(isCompatible(T.literal(42), T.literal(42))).toBe(true);
82
+ expect(isCompatible(T.literal(true), T.literal(true))).toBe(true);
83
+ });
84
+
85
+ it('different literal values do not match', () => {
86
+ expect(isCompatible(T.literal('hello'), T.literal('world'))).toBe(false);
87
+ expect(isCompatible(T.literal(1), T.literal(2))).toBe(false);
88
+ });
89
+
90
+ it('literal widens to matching primitive', () => {
91
+ expect(isCompatible(T.literal('hello'), T.string)).toBe(true);
92
+ expect(isCompatible(T.literal(42), T.number)).toBe(true);
93
+ expect(isCompatible(T.literal(true), T.boolean)).toBe(true);
94
+ });
95
+
96
+ it('any literal widens to string', () => {
97
+ expect(isCompatible(T.literal(42), T.string)).toBe(true);
98
+ expect(isCompatible(T.literal(true), T.string)).toBe(true);
99
+ });
100
+ });
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────
103
+ // Objects (structural subtyping)
104
+ // ─────────────────────────────────────────────────────────────────────────
105
+
106
+ describe('objects', () => {
107
+ it('same shape is compatible', () => {
108
+ expect(isCompatible(T.obj({ name: T.string }), T.obj({ name: T.string }))).toBe(true);
109
+ });
110
+
111
+ it('output with extra fields satisfies input', () => {
112
+ expect(
113
+ isCompatible(T.obj({ name: T.string, age: T.number }), T.obj({ name: T.string }))
114
+ ).toBe(true);
115
+ });
116
+
117
+ it('output missing required field is incompatible', () => {
118
+ expect(
119
+ isCompatible(T.obj({ name: T.string }), T.obj({ name: T.string, age: T.number }))
120
+ ).toBe(false);
121
+ });
122
+
123
+ it('output missing optional field is compatible', () => {
124
+ const input: Parameters<typeof isCompatible>[1] = {
125
+ kind: 'object',
126
+ fields: {
127
+ name: { type: T.string, optional: false },
128
+ age: { type: T.number, optional: true },
129
+ },
130
+ };
131
+ expect(isCompatible(T.obj({ name: T.string }), input)).toBe(true);
132
+ });
133
+
134
+ it('field type mismatch is incompatible', () => {
135
+ expect(isCompatible(T.obj({ name: T.boolean }), T.obj({ name: T.number }))).toBe(false);
136
+ });
137
+
138
+ it('field type widening works (number → string)', () => {
139
+ expect(isCompatible(T.obj({ name: T.number }), T.obj({ name: T.string }))).toBe(true);
140
+ });
141
+
142
+ it('nested objects are checked structurally', () => {
143
+ const output = T.obj({ user: T.obj({ name: T.string }) });
144
+ const input = T.obj({ user: T.obj({ name: T.string }) });
145
+ expect(isCompatible(output, input)).toBe(true);
146
+ });
147
+
148
+ it('empty objects are compatible', () => {
149
+ expect(isCompatible(T.obj({}), T.obj({}))).toBe(true);
150
+ });
151
+ });
152
+
153
+ // ─────────────────────────────────────────────────────────────────────────
154
+ // Arrays
155
+ // ─────────────────────────────────────────────────────────────────────────
156
+
157
+ describe('arrays', () => {
158
+ it('same element types are compatible', () => {
159
+ expect(isCompatible(T.array(T.string), T.array(T.string))).toBe(true);
160
+ expect(isCompatible(T.array(T.number), T.array(T.number))).toBe(true);
161
+ });
162
+
163
+ it('different element types are incompatible', () => {
164
+ expect(isCompatible(T.array(T.string), T.array(T.number))).toBe(false);
165
+ });
166
+
167
+ it('element type widening works', () => {
168
+ expect(isCompatible(T.array(T.number), T.array(T.string))).toBe(true);
169
+ });
170
+
171
+ it('array is not compatible with non-array', () => {
172
+ expect(isCompatible(T.array(T.string), T.string)).toBe(false);
173
+ });
174
+ });
175
+
176
+ // ─────────────────────────────────────────────────────────────────────────
177
+ // Tuples
178
+ // ─────────────────────────────────────────────────────────────────────────
179
+
180
+ describe('tuples', () => {
181
+ it('same tuple is compatible', () => {
182
+ expect(isCompatible(T.tuple([T.string, T.number]), T.tuple([T.string, T.number]))).toBe(
183
+ true
184
+ );
185
+ });
186
+
187
+ it('different length tuples are incompatible', () => {
188
+ expect(isCompatible(T.tuple([T.string]), T.tuple([T.string, T.number]))).toBe(false);
189
+ });
190
+
191
+ it('element type mismatch is incompatible', () => {
192
+ expect(isCompatible(T.tuple([T.string, T.string]), T.tuple([T.string, T.number]))).toBe(
193
+ false
194
+ );
195
+ });
196
+ });
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────
199
+ // Unions
200
+ // ─────────────────────────────────────────────────────────────────────────
201
+
202
+ describe('unions', () => {
203
+ it('output union: all variants must satisfy input', () => {
204
+ // string | number → string (number widens to string)
205
+ expect(isCompatible(T.union([T.string, T.number]), T.string)).toBe(true);
206
+
207
+ // string | object → string (object does NOT widen to string)
208
+ expect(
209
+ isCompatible(T.union([T.string, T.obj({ x: T.number })]), T.string)
210
+ ).toBe(false);
211
+ });
212
+
213
+ it('input union: output must satisfy at least one variant', () => {
214
+ // number → string | number
215
+ expect(isCompatible(T.number, T.union([T.string, T.number]))).toBe(true);
216
+
217
+ // boolean → string | number (boolean widens to string, which is a variant)
218
+ expect(isCompatible(T.boolean, T.union([T.string, T.number]))).toBe(true);
219
+ });
220
+
221
+ it('union to union: all output variants satisfy at least one input variant', () => {
222
+ expect(
223
+ isCompatible(T.union([T.string, T.number]), T.union([T.string, T.number]))
224
+ ).toBe(true);
225
+ });
226
+ });
227
+
228
+ // ─────────────────────────────────────────────────────────────────────────
229
+ // Records
230
+ // ─────────────────────────────────────────────────────────────────────────
231
+
232
+ describe('records', () => {
233
+ it('same value types are compatible', () => {
234
+ expect(isCompatible(T.record(T.string), T.record(T.string))).toBe(true);
235
+ });
236
+
237
+ it('different value types are incompatible', () => {
238
+ expect(isCompatible(T.record(T.string), T.record(T.number))).toBe(false);
239
+ });
240
+
241
+ it('record can satisfy object if value type is compatible', () => {
242
+ expect(isCompatible(T.record(T.string), T.obj({ name: T.string }))).toBe(true);
243
+ });
244
+
245
+ it('record cannot satisfy object if value type is incompatible', () => {
246
+ // number → string widens, so use a truly incompatible type
247
+ expect(isCompatible(T.record(T.boolean), T.obj({ name: T.number }))).toBe(false);
248
+ });
249
+
250
+ it('object can satisfy record if all field types are compatible', () => {
251
+ expect(isCompatible(T.obj({ a: T.string, b: T.string }), T.record(T.string))).toBe(true);
252
+ });
253
+ });
254
+
255
+ // ─────────────────────────────────────────────────────────────────────────
256
+ // Enums
257
+ // ─────────────────────────────────────────────────────────────────────────
258
+
259
+ describe('enums', () => {
260
+ it('same enum values are compatible', () => {
261
+ expect(isCompatible(T.enum(['a', 'b']), T.enum(['a', 'b']))).toBe(true);
262
+ });
263
+
264
+ it('subset enum satisfies superset', () => {
265
+ expect(isCompatible(T.enum(['a']), T.enum(['a', 'b']))).toBe(true);
266
+ });
267
+
268
+ it('superset enum does NOT satisfy subset', () => {
269
+ expect(isCompatible(T.enum(['a', 'b', 'c']), T.enum(['a', 'b']))).toBe(false);
270
+ });
271
+
272
+ it('string enum widens to string primitive', () => {
273
+ expect(isCompatible(T.enum(['a', 'b']), T.string)).toBe(true);
274
+ });
275
+
276
+ it('number enum widens to number primitive', () => {
277
+ expect(isCompatible(T.enum([1, 2, 3]), T.number)).toBe(true);
278
+ });
279
+ });
280
+
281
+ // ─────────────────────────────────────────────────────────────────────────
282
+ // Passthrough & Resolved (unresolved markers)
283
+ // ─────────────────────────────────────────────────────────────────────────
284
+
285
+ describe('unresolved markers', () => {
286
+ it('passthrough input accepts anything', () => {
287
+ expect(isCompatible(T.string, T.passthrough('in'))).toBe(true);
288
+ });
289
+
290
+ it('passthrough output is accepted by anything', () => {
291
+ expect(isCompatible(T.passthrough('in'), T.string)).toBe(true);
292
+ });
293
+
294
+ it('resolved input accepts anything', () => {
295
+ expect(isCompatible(T.string, T.resolved('spark', 'sparkType'))).toBe(true);
296
+ });
297
+
298
+ it('resolved output is accepted by anything', () => {
299
+ expect(isCompatible(T.resolved('spark', 'sparkType'), T.string)).toBe(true);
300
+ });
301
+ });
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────
304
+ // Cross-kind compatibility (edge cases from existing tests)
305
+ // ─────────────────────────────────────────────────────────────────────────
306
+
307
+ describe('cross-kind', () => {
308
+ it('object is not compatible with string', () => {
309
+ expect(isCompatible(T.obj({ x: T.number }), T.string)).toBe(false);
310
+ });
311
+
312
+ it('string is not compatible with object', () => {
313
+ expect(isCompatible(T.string, T.obj({ x: T.number }))).toBe(false);
314
+ });
315
+
316
+ it('array is not compatible with object', () => {
317
+ expect(isCompatible(T.array(T.string), T.obj({}))).toBe(false);
318
+ });
319
+ });
320
+ });
@@ -0,0 +1,312 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { T } from '../descriptor';
3
+ import { type GraphEdge, type GraphNode, type TypeResolver, inferTypes, portKey } from '../inference';
4
+
5
+ function makeNode(
6
+ id: string,
7
+ ports: Record<string, { direction: 'input' | 'output'; type: import('../descriptor').TypeDescriptor }>
8
+ ): GraphNode {
9
+ return { id, ports };
10
+ }
11
+
12
+ function makeEdge(
13
+ sourceNode: string,
14
+ sourcePort: string,
15
+ targetNode: string,
16
+ targetPort: string
17
+ ): GraphEdge {
18
+ return { sourceNode, sourcePort, targetNode, targetPort };
19
+ }
20
+
21
+ describe('inferTypes', () => {
22
+ // ─────────────────────────────────────────────────────────────────────────
23
+ // Concrete types (no inference needed)
24
+ // ─────────────────────────────────────────────────────────────────────────
25
+
26
+ it('returns concrete types as-is', () => {
27
+ const nodes = [
28
+ makeNode('a', {
29
+ out: { direction: 'output', type: T.string },
30
+ }),
31
+ ];
32
+ const result = inferTypes(nodes, []);
33
+ expect(result.get(portKey('a', 'out'))).toEqual(T.string);
34
+ });
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────
37
+ // Forward propagation
38
+ // ─────────────────────────────────────────────────────────────────────────
39
+
40
+ it('propagates concrete output type to generic input', () => {
41
+ const nodes = [
42
+ makeNode('a', {
43
+ out: { direction: 'output', type: T.obj({ name: T.string }) },
44
+ }),
45
+ makeNode('b', {
46
+ in: { direction: 'input', type: T.generic() },
47
+ }),
48
+ ];
49
+ const edges = [makeEdge('a', 'out', 'b', 'in')];
50
+
51
+ const result = inferTypes(nodes, edges);
52
+ expect(result.get(portKey('b', 'in'))).toEqual(T.obj({ name: T.string }));
53
+ });
54
+
55
+ it('does not overwrite concrete input types', () => {
56
+ const nodes = [
57
+ makeNode('a', {
58
+ out: { direction: 'output', type: T.string },
59
+ }),
60
+ makeNode('b', {
61
+ in: { direction: 'input', type: T.number },
62
+ }),
63
+ ];
64
+ const edges = [makeEdge('a', 'out', 'b', 'in')];
65
+
66
+ const result = inferTypes(nodes, edges);
67
+ expect(result.get(portKey('b', 'in'))).toEqual(T.number); // unchanged
68
+ });
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+ // Passthrough resolution
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+
74
+ it('resolves passthrough output from input type', () => {
75
+ const nodes = [
76
+ makeNode('a', {
77
+ out: { direction: 'output', type: T.obj({ x: T.number }) },
78
+ }),
79
+ makeNode('cond', {
80
+ in: { direction: 'input', type: T.generic() },
81
+ then: { direction: 'output', type: T.passthrough('in') },
82
+ else: { direction: 'output', type: T.passthrough('in') },
83
+ }),
84
+ ];
85
+ const edges = [makeEdge('a', 'out', 'cond', 'in')];
86
+
87
+ const result = inferTypes(nodes, edges);
88
+
89
+ // Input gets the type from 'a'
90
+ expect(result.get(portKey('cond', 'in'))).toEqual(T.obj({ x: T.number }));
91
+
92
+ // Passthrough outputs inherit from input
93
+ expect(result.get(portKey('cond', 'then'))).toEqual(T.obj({ x: T.number }));
94
+ expect(result.get(portKey('cond', 'else'))).toEqual(T.obj({ x: T.number }));
95
+ });
96
+
97
+ it('chains passthrough across multiple blocks', () => {
98
+ const nodes = [
99
+ makeNode('source', {
100
+ out: { direction: 'output', type: T.obj({ id: T.string }) },
101
+ }),
102
+ makeNode('delay', {
103
+ in: { direction: 'input', type: T.generic() },
104
+ out: { direction: 'output', type: T.passthrough('in') },
105
+ }),
106
+ makeNode('cond', {
107
+ in: { direction: 'input', type: T.generic() },
108
+ then: { direction: 'output', type: T.passthrough('in') },
109
+ }),
110
+ ];
111
+ const edges = [
112
+ makeEdge('source', 'out', 'delay', 'in'),
113
+ makeEdge('delay', 'out', 'cond', 'in'),
114
+ ];
115
+
116
+ const result = inferTypes(nodes, edges);
117
+ expect(result.get(portKey('cond', 'then'))).toEqual(T.obj({ id: T.string }));
118
+ });
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+ // Backward propagation
122
+ // ─────────────────────────────────────────────────────────────────────────
123
+
124
+ it('propagates concrete input type back to generic output', () => {
125
+ const nodes = [
126
+ makeNode('a', {
127
+ out: { direction: 'output', type: T.generic() },
128
+ }),
129
+ makeNode('b', {
130
+ in: { direction: 'input', type: T.number },
131
+ }),
132
+ ];
133
+ const edges = [makeEdge('a', 'out', 'b', 'in')];
134
+
135
+ const result = inferTypes(nodes, edges);
136
+ expect(result.get(portKey('a', 'out'))).toEqual(T.number);
137
+ });
138
+
139
+ // ─────────────────────────────────────────────────────────────────────────
140
+ // External type resolution ($resolve)
141
+ // ─────────────────────────────────────────────────────────────────────────
142
+
143
+ it('resolves $resolve markers via TypeResolver', () => {
144
+ const nodes: GraphNode[] = [
145
+ {
146
+ id: 'spark-trigger',
147
+ ports: {
148
+ out: { direction: 'output', type: T.resolved('spark', 'sparkType') },
149
+ },
150
+ config: { sparkType: 'timer:timer-started' },
151
+ },
152
+ ];
153
+
154
+ const resolver: TypeResolver = {
155
+ resolve(source, key) {
156
+ if (source === 'spark' && key === 'timer:timer-started') {
157
+ return T.obj({ duration: T.number, name: T.string });
158
+ }
159
+ return null;
160
+ },
161
+ };
162
+
163
+ const result = inferTypes(nodes, [], resolver);
164
+ expect(result.get(portKey('spark-trigger', 'out'))).toEqual(
165
+ T.obj({ duration: T.number, name: T.string })
166
+ );
167
+ });
168
+
169
+ it('resolved type propagates to downstream generic inputs', () => {
170
+ const nodes: GraphNode[] = [
171
+ {
172
+ id: 'trigger',
173
+ ports: {
174
+ out: { direction: 'output', type: T.resolved('spark', 'sparkType') },
175
+ },
176
+ config: { sparkType: 'timer:tick' },
177
+ },
178
+ makeNode('handler', {
179
+ in: { direction: 'input', type: T.generic() },
180
+ out: { direction: 'output', type: T.passthrough('in') },
181
+ }),
182
+ ];
183
+ const edges = [makeEdge('trigger', 'out', 'handler', 'in')];
184
+
185
+ const resolver: TypeResolver = {
186
+ resolve(source, key) {
187
+ if (source === 'spark' && key === 'timer:tick') {
188
+ return T.obj({ count: T.number });
189
+ }
190
+ return null;
191
+ },
192
+ };
193
+
194
+ const result = inferTypes(nodes, edges, resolver);
195
+ expect(result.get(portKey('handler', 'in'))).toEqual(T.obj({ count: T.number }));
196
+ expect(result.get(portKey('handler', 'out'))).toEqual(T.obj({ count: T.number }));
197
+ });
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────
200
+ // Complex graph
201
+ // ─────────────────────────────────────────────────────────────────────────
202
+
203
+ it('handles a realistic workflow graph', () => {
204
+ const nodes: GraphNode[] = [
205
+ // Spark trigger → outputs {duration: number, name: string}
206
+ {
207
+ id: 'trigger',
208
+ ports: {
209
+ out: { direction: 'output', type: T.resolved('spark', 'sparkType') },
210
+ },
211
+ config: { sparkType: 'timer:completed' },
212
+ },
213
+ // Condition block → passthrough
214
+ makeNode('check', {
215
+ in: { direction: 'input', type: T.generic() },
216
+ then: { direction: 'output', type: T.passthrough('in') },
217
+ else: { direction: 'output', type: T.passthrough('in') },
218
+ }),
219
+ // Log block → generic in, passthrough out
220
+ makeNode('log', {
221
+ in: { direction: 'input', type: T.generic() },
222
+ out: { direction: 'output', type: T.passthrough('in') },
223
+ }),
224
+ // HTTP block → concrete input, concrete output
225
+ makeNode('http', {
226
+ trigger: { direction: 'input', type: T.generic() },
227
+ response: { direction: 'output', type: T.obj({ status: T.number, body: T.string }) },
228
+ error: { direction: 'output', type: T.obj({ message: T.string }) },
229
+ }),
230
+ ];
231
+
232
+ const edges = [
233
+ makeEdge('trigger', 'out', 'check', 'in'),
234
+ makeEdge('check', 'then', 'log', 'in'),
235
+ makeEdge('check', 'else', 'http', 'trigger'),
236
+ ];
237
+
238
+ const resolver: TypeResolver = {
239
+ resolve(source, key) {
240
+ if (source === 'spark' && key === 'timer:completed') {
241
+ return T.obj({ duration: T.number, name: T.string });
242
+ }
243
+ return null;
244
+ },
245
+ };
246
+
247
+ const result = inferTypes(nodes, edges, resolver);
248
+
249
+ const timerType = T.obj({ duration: T.number, name: T.string });
250
+
251
+ // Trigger output resolved
252
+ expect(result.get(portKey('trigger', 'out'))).toEqual(timerType);
253
+
254
+ // Condition gets type from trigger
255
+ expect(result.get(portKey('check', 'in'))).toEqual(timerType);
256
+ expect(result.get(portKey('check', 'then'))).toEqual(timerType);
257
+ expect(result.get(portKey('check', 'else'))).toEqual(timerType);
258
+
259
+ // Log gets type from condition.then
260
+ expect(result.get(portKey('log', 'in'))).toEqual(timerType);
261
+ expect(result.get(portKey('log', 'out'))).toEqual(timerType);
262
+
263
+ // HTTP trigger gets type from condition.else
264
+ expect(result.get(portKey('http', 'trigger'))).toEqual(timerType);
265
+
266
+ // HTTP concrete outputs are unchanged
267
+ expect(result.get(portKey('http', 'response'))).toEqual(
268
+ T.obj({ status: T.number, body: T.string })
269
+ );
270
+ expect(result.get(portKey('http', 'error'))).toEqual(T.obj({ message: T.string }));
271
+ });
272
+
273
+ // ─────────────────────────────────────────────────────────────────────────
274
+ // Edge cases
275
+ // ─────────────────────────────────────────────────────────────────────────
276
+
277
+ it('handles disconnected nodes', () => {
278
+ const nodes = [
279
+ makeNode('a', { out: { direction: 'output', type: T.string } }),
280
+ makeNode('b', { in: { direction: 'input', type: T.generic() } }),
281
+ ];
282
+ // No edges
283
+
284
+ const result = inferTypes(nodes, []);
285
+ expect(result.get(portKey('a', 'out'))).toEqual(T.string);
286
+ expect(result.has(portKey('b', 'in'))).toBe(false); // generic, not resolved
287
+ });
288
+
289
+ it('handles empty graph', () => {
290
+ const result = inferTypes([], []);
291
+ expect(result.size).toBe(0);
292
+ });
293
+
294
+ it('handles circular connections gracefully', () => {
295
+ const nodes = [
296
+ makeNode('a', {
297
+ in: { direction: 'input', type: T.generic() },
298
+ out: { direction: 'output', type: T.passthrough('in') },
299
+ }),
300
+ makeNode('b', {
301
+ in: { direction: 'input', type: T.generic() },
302
+ out: { direction: 'output', type: T.passthrough('in') },
303
+ }),
304
+ ];
305
+ const edges = [makeEdge('a', 'out', 'b', 'in'), makeEdge('b', 'out', 'a', 'in')];
306
+
307
+ // Should not infinite loop, just leave them unresolved
308
+ const result = inferTypes(nodes, edges);
309
+ expect(result.has(portKey('a', 'in'))).toBe(false);
310
+ expect(result.has(portKey('b', 'in'))).toBe(false);
311
+ });
312
+ });