@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.
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +23 -0
- package/src/__tests__/autocomplete.test.ts +126 -0
- package/src/__tests__/compatibility.test.ts +320 -0
- package/src/__tests__/inference.test.ts +312 -0
- package/src/autocomplete.ts +174 -0
- package/src/compatibility.ts +200 -0
- package/src/descriptor.ts +157 -0
- package/src/display.ts +71 -0
- package/src/from-zod.ts +293 -0
- package/src/index.ts +31 -0
- package/src/inference.ts +275 -0
- package/src/to-json-schema.ts +65 -0
- package/tsconfig.json +9 -0
|
@@ -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
|
+
});
|