@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
package/src/from-zod.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod → TypeDescriptor conversion.
|
|
3
|
+
*
|
|
4
|
+
* Converts Zod schemas to TypeDescriptor, handling:
|
|
5
|
+
* - Standard Zod types (string, number, object, array, union, enum, etc.)
|
|
6
|
+
* - GenericRef, PassthroughRef, ResolvedRef marker objects from @brika/sdk
|
|
7
|
+
*
|
|
8
|
+
* This function is used at block registration time (SDK side) to produce
|
|
9
|
+
* TypeDescriptor metadata that ships over IPC.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TypeDescriptor } from './descriptor';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a Zod schema or SDK marker ref to a TypeDescriptor.
|
|
16
|
+
*
|
|
17
|
+
* @param schemaOrRef - A Zod schema, GenericRef, PassthroughRef, or ResolvedRef
|
|
18
|
+
* @returns TypeDescriptor
|
|
19
|
+
*/
|
|
20
|
+
export function zodToDescriptor(schemaOrRef: unknown): TypeDescriptor {
|
|
21
|
+
// Handle SDK marker refs (GenericRef, PassthroughRef, ResolvedRef)
|
|
22
|
+
if (isMarkerRef(schemaOrRef)) {
|
|
23
|
+
return markerToDescriptor(schemaOrRef);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Handle Zod schemas via JSON Schema intermediary
|
|
27
|
+
// This avoids depending on Zod internals
|
|
28
|
+
return fromZodViaJsonSchema(schemaOrRef);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Marker Ref Detection
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
interface MarkerRef {
|
|
36
|
+
__type: 'generic' | 'passthrough' | 'resolved';
|
|
37
|
+
__generic?: string;
|
|
38
|
+
__passthrough?: string;
|
|
39
|
+
__source?: string;
|
|
40
|
+
__configField?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isMarkerRef(value: unknown): value is MarkerRef {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === 'object' &&
|
|
46
|
+
value !== null &&
|
|
47
|
+
'__type' in value &&
|
|
48
|
+
typeof (value as MarkerRef).__type === 'string' &&
|
|
49
|
+
['generic', 'passthrough', 'resolved'].includes((value as MarkerRef).__type)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function markerToDescriptor(ref: MarkerRef): TypeDescriptor {
|
|
54
|
+
switch (ref.__type) {
|
|
55
|
+
case 'generic':
|
|
56
|
+
return { kind: 'generic', typeVar: ref.__generic ?? 'T' };
|
|
57
|
+
case 'passthrough':
|
|
58
|
+
return { kind: 'passthrough', sourcePortId: ref.__passthrough ?? '' };
|
|
59
|
+
case 'resolved':
|
|
60
|
+
return { kind: 'resolved', source: ref.__source ?? '', configField: ref.__configField ?? '' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Zod → TypeDescriptor (via JSON Schema)
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function fromZodViaJsonSchema(schema: unknown): TypeDescriptor {
|
|
69
|
+
try {
|
|
70
|
+
// Try using Zod's built-in toJSONSchema (Zod v4+)
|
|
71
|
+
const zod = getZodModule(schema);
|
|
72
|
+
if (zod?.toJSONSchema) {
|
|
73
|
+
const jsonSchema = zod.toJSONSchema(schema, { unrepresentable: 'any' }) as Record<
|
|
74
|
+
string,
|
|
75
|
+
unknown
|
|
76
|
+
>;
|
|
77
|
+
return fromJsonSchema(jsonSchema);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Fall through to constructor-based detection
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: detect type from Zod constructor name
|
|
84
|
+
return fromZodConstructor(schema);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Try to get the Zod module from a schema instance.
|
|
89
|
+
* Avoids a direct import of Zod so this package stays dependency-free.
|
|
90
|
+
*/
|
|
91
|
+
function getZodModule(
|
|
92
|
+
schema: unknown
|
|
93
|
+
): { toJSONSchema: (s: unknown, opts: Record<string, unknown>) => unknown } | null {
|
|
94
|
+
try {
|
|
95
|
+
// Zod v4 schemas have a registry reference
|
|
96
|
+
const s = schema as { constructor?: { name?: string }; '~standard'?: unknown };
|
|
97
|
+
if (s.constructor?.name?.startsWith('Zod') || s['~standard']) {
|
|
98
|
+
// Dynamic import would be async, so we use a direct approach:
|
|
99
|
+
// The caller should have Zod in scope when using this function
|
|
100
|
+
// We look for toJSONSchema on the Zod namespace
|
|
101
|
+
const zod = (globalThis as Record<string, unknown>).__brika_zod as
|
|
102
|
+
| { toJSONSchema: (s: unknown, opts: Record<string, unknown>) => unknown }
|
|
103
|
+
| undefined;
|
|
104
|
+
return zod ?? null;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert a JSON Schema to TypeDescriptor.
|
|
114
|
+
* This is the primary conversion path and handles the full JSON Schema spec subset we use.
|
|
115
|
+
*/
|
|
116
|
+
export function fromJsonSchema(schema: Record<string, unknown>): TypeDescriptor {
|
|
117
|
+
const type = schema.type as string | undefined;
|
|
118
|
+
|
|
119
|
+
// anyOf → union
|
|
120
|
+
if (schema.anyOf) {
|
|
121
|
+
const variants = (schema.anyOf as Record<string, unknown>[]).map(fromJsonSchema);
|
|
122
|
+
return variants.length === 1 && variants[0] ? variants[0] : { kind: 'union', variants };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// oneOf → union
|
|
126
|
+
if (schema.oneOf) {
|
|
127
|
+
const variants = (schema.oneOf as Record<string, unknown>[]).map(fromJsonSchema);
|
|
128
|
+
return variants.length === 1 && variants[0] ? variants[0] : { kind: 'union', variants };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// enum
|
|
132
|
+
if (schema.enum) {
|
|
133
|
+
const values = schema.enum as (string | number)[];
|
|
134
|
+
return { kind: 'enum', values };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// const → literal
|
|
138
|
+
if ('const' in schema) {
|
|
139
|
+
return { kind: 'literal', value: schema.const as string | number | boolean };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
switch (type) {
|
|
143
|
+
case 'string':
|
|
144
|
+
return { kind: 'primitive', type: 'string' };
|
|
145
|
+
case 'number':
|
|
146
|
+
case 'integer':
|
|
147
|
+
return { kind: 'primitive', type: 'number' };
|
|
148
|
+
case 'boolean':
|
|
149
|
+
return { kind: 'primitive', type: 'boolean' };
|
|
150
|
+
case 'null':
|
|
151
|
+
return { kind: 'primitive', type: 'null' };
|
|
152
|
+
|
|
153
|
+
case 'array': {
|
|
154
|
+
if (schema.items) {
|
|
155
|
+
return { kind: 'array', element: fromJsonSchema(schema.items as Record<string, unknown>) };
|
|
156
|
+
}
|
|
157
|
+
if (schema.prefixItems) {
|
|
158
|
+
const elements = (schema.prefixItems as Record<string, unknown>[]).map(fromJsonSchema);
|
|
159
|
+
return { kind: 'tuple', elements };
|
|
160
|
+
}
|
|
161
|
+
return { kind: 'array', element: { kind: 'unknown' } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'object': {
|
|
165
|
+
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined;
|
|
166
|
+
if (!properties) {
|
|
167
|
+
// Bare object or Record type
|
|
168
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'record',
|
|
171
|
+
value: fromJsonSchema(schema.additionalProperties as Record<string, unknown>),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { kind: 'record', value: { kind: 'unknown' } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const required = new Set((schema.required as string[] | undefined) ?? []);
|
|
178
|
+
const fields: Record<string, { type: TypeDescriptor; optional: boolean }> = {};
|
|
179
|
+
|
|
180
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
181
|
+
fields[key] = {
|
|
182
|
+
type: fromJsonSchema(propSchema),
|
|
183
|
+
optional: !required.has(key),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { kind: 'object', fields };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
default:
|
|
191
|
+
// Unknown or mixed type
|
|
192
|
+
return { kind: 'unknown' };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
// Fallback: Constructor-based detection
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function fromZodConstructor(schema: unknown): TypeDescriptor {
|
|
201
|
+
const name = getZodTypeName(schema);
|
|
202
|
+
|
|
203
|
+
switch (name) {
|
|
204
|
+
case 'string':
|
|
205
|
+
return { kind: 'primitive', type: 'string' };
|
|
206
|
+
case 'number':
|
|
207
|
+
return { kind: 'primitive', type: 'number' };
|
|
208
|
+
case 'boolean':
|
|
209
|
+
return { kind: 'primitive', type: 'boolean' };
|
|
210
|
+
case 'null':
|
|
211
|
+
return { kind: 'primitive', type: 'null' };
|
|
212
|
+
case 'any':
|
|
213
|
+
return { kind: 'any' };
|
|
214
|
+
case 'unknown':
|
|
215
|
+
return { kind: 'unknown' };
|
|
216
|
+
case 'undefined':
|
|
217
|
+
return { kind: 'unknown' };
|
|
218
|
+
|
|
219
|
+
case 'array': {
|
|
220
|
+
const def = getDef(schema);
|
|
221
|
+
const element = def?.element ?? def?.type;
|
|
222
|
+
return {
|
|
223
|
+
kind: 'array',
|
|
224
|
+
element: element ? fromZodConstructor(element) : { kind: 'unknown' },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case 'object': {
|
|
229
|
+
const def = getDef(schema);
|
|
230
|
+
const shape = def?.shape;
|
|
231
|
+
if (!shape || typeof shape !== 'object') {
|
|
232
|
+
return { kind: 'record', value: { kind: 'unknown' } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fields: Record<string, { type: TypeDescriptor; optional: boolean }> = {};
|
|
236
|
+
for (const [key, fieldSchema] of Object.entries(shape as Record<string, unknown>)) {
|
|
237
|
+
const fieldName = getZodTypeName(fieldSchema);
|
|
238
|
+
fields[key] = {
|
|
239
|
+
type: fromZodConstructor(fieldName === 'optional' ? getInner(fieldSchema) : fieldSchema),
|
|
240
|
+
optional: fieldName === 'optional',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { kind: 'object', fields };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'optional':
|
|
247
|
+
case 'nullable':
|
|
248
|
+
return fromZodConstructor(getInner(schema));
|
|
249
|
+
|
|
250
|
+
case 'union': {
|
|
251
|
+
const def = getDef(schema);
|
|
252
|
+
const options = def?.options as unknown[] | undefined;
|
|
253
|
+
if (options) {
|
|
254
|
+
return { kind: 'union', variants: options.map(fromZodConstructor) };
|
|
255
|
+
}
|
|
256
|
+
return { kind: 'unknown' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case 'enum': {
|
|
260
|
+
const def = getDef(schema);
|
|
261
|
+
const values = def?.entries ?? def?.values;
|
|
262
|
+
if (Array.isArray(values)) {
|
|
263
|
+
return { kind: 'enum', values: values as (string | number)[] };
|
|
264
|
+
}
|
|
265
|
+
return { kind: 'unknown' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'record':
|
|
269
|
+
return { kind: 'record', value: { kind: 'unknown' } };
|
|
270
|
+
|
|
271
|
+
default:
|
|
272
|
+
return { kind: 'unknown' };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getZodTypeName(schema: unknown): string {
|
|
277
|
+
const s = schema as { constructor?: { name?: string } };
|
|
278
|
+
const name = s?.constructor?.name;
|
|
279
|
+
if (name?.startsWith('Zod')) {
|
|
280
|
+
return name.slice(3).toLowerCase();
|
|
281
|
+
}
|
|
282
|
+
return 'unknown';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getDef(schema: unknown): Record<string, unknown> | null {
|
|
286
|
+
const s = schema as { _def?: Record<string, unknown> };
|
|
287
|
+
return s?._def ?? null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getInner(schema: unknown): unknown {
|
|
291
|
+
const def = getDef(schema);
|
|
292
|
+
return def?.innerType ?? def?.unwrapped ?? schema;
|
|
293
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/type-system — unified type system for workflow port types.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for port types across backend and frontend.
|
|
5
|
+
* Provides:
|
|
6
|
+
* - TypeDescriptor: serializable, structural type representation
|
|
7
|
+
* - isCompatible: structural type compatibility checking
|
|
8
|
+
* - inferTypes: graph-based type inference for generic/passthrough/resolved ports
|
|
9
|
+
* - getCompletions: autocomplete items from resolved types
|
|
10
|
+
* - displayType: human-readable type name strings
|
|
11
|
+
* - zodToDescriptor: Zod schema → TypeDescriptor conversion
|
|
12
|
+
* - fromJsonSchema: JSON Schema → TypeDescriptor conversion
|
|
13
|
+
* - toJsonSchema: TypeDescriptor → JSON Schema conversion
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type { PrimitiveType, TypeDescriptor } from './descriptor';
|
|
17
|
+
export { T, isConcrete, isWildcard, needsResolution, parseTypeName, parsePortType, inferType } from './descriptor';
|
|
18
|
+
|
|
19
|
+
export { displayType } from './display';
|
|
20
|
+
|
|
21
|
+
export { isCompatible } from './compatibility';
|
|
22
|
+
|
|
23
|
+
export type { CompletionItem } from './autocomplete';
|
|
24
|
+
export { getCompletions } from './autocomplete';
|
|
25
|
+
|
|
26
|
+
export type { GraphEdge, GraphNode, PortTypeMap, TypeResolver } from './inference';
|
|
27
|
+
export { inferTypes, portKey } from './inference';
|
|
28
|
+
|
|
29
|
+
export { fromJsonSchema, zodToDescriptor } from './from-zod';
|
|
30
|
+
|
|
31
|
+
export { toJsonSchema } from './to-json-schema';
|
package/src/inference.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph Type Inference Engine
|
|
3
|
+
*
|
|
4
|
+
* Resolves generic, passthrough, and resolved port types
|
|
5
|
+
* based on the workflow graph structure and connections.
|
|
6
|
+
*
|
|
7
|
+
* Algorithm:
|
|
8
|
+
* 1. Resolve external types ($resolve markers via TypeResolver)
|
|
9
|
+
* 2. Forward propagation: concrete output → connected generic input
|
|
10
|
+
* 3. Passthrough resolution: passthrough(inputId) → copy input's resolved type
|
|
11
|
+
* 4. Backward propagation: concrete input → connected generic output
|
|
12
|
+
* 5. Iterate until stable (max 10 passes)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type TypeDescriptor, isConcrete, needsResolution } from './descriptor';
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// Graph Types
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface GraphNode {
|
|
22
|
+
id: string;
|
|
23
|
+
ports: Record<string, { direction: 'input' | 'output'; type: TypeDescriptor }>;
|
|
24
|
+
config?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GraphEdge {
|
|
28
|
+
sourceNode: string;
|
|
29
|
+
sourcePort: string;
|
|
30
|
+
targetNode: string;
|
|
31
|
+
targetPort: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* External type resolver — looks up types from external sources.
|
|
36
|
+
* e.g., spark registry for $resolve:spark:sparkType
|
|
37
|
+
*/
|
|
38
|
+
export interface TypeResolver {
|
|
39
|
+
resolve(source: string, key: string): TypeDescriptor | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map of "nodeId:portId" → resolved TypeDescriptor */
|
|
43
|
+
export type PortTypeMap = Map<string, TypeDescriptor>;
|
|
44
|
+
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
// Main Entry Point
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const MAX_ITERATIONS = 10;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Infer types for all ports in a workflow graph.
|
|
53
|
+
* Returns a map of resolved types for every port.
|
|
54
|
+
*/
|
|
55
|
+
export function inferTypes(
|
|
56
|
+
nodes: GraphNode[],
|
|
57
|
+
edges: GraphEdge[],
|
|
58
|
+
resolver?: TypeResolver
|
|
59
|
+
): PortTypeMap {
|
|
60
|
+
const result: PortTypeMap = new Map();
|
|
61
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
62
|
+
|
|
63
|
+
// Build edge lookup maps
|
|
64
|
+
const incoming = buildIncomingMap(edges);
|
|
65
|
+
const outgoing = buildOutgoingMap(edges);
|
|
66
|
+
|
|
67
|
+
// Seed with declared types (concrete types go directly into result)
|
|
68
|
+
for (const node of nodes) {
|
|
69
|
+
for (const [portId, port] of Object.entries(node.ports)) {
|
|
70
|
+
const key = portKey(node.id, portId);
|
|
71
|
+
if (isConcrete(port.type)) {
|
|
72
|
+
result.set(key, port.type);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Phase 1: Resolve external types ($resolve markers)
|
|
78
|
+
if (resolver) {
|
|
79
|
+
resolveExternalTypes(nodes, resolver, result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Phase 2-4: Iterate until stable
|
|
83
|
+
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
|
84
|
+
let changed = false;
|
|
85
|
+
|
|
86
|
+
for (const node of nodes) {
|
|
87
|
+
// Forward propagation: concrete output → connected generic input
|
|
88
|
+
if (propagateForward(node, incoming, nodeMap, result)) changed = true;
|
|
89
|
+
|
|
90
|
+
// Passthrough resolution
|
|
91
|
+
if (resolvePassthrough(node, result)) changed = true;
|
|
92
|
+
|
|
93
|
+
// Backward propagation: concrete input → connected generic output
|
|
94
|
+
if (propagateBackward(node, outgoing, nodeMap, result)) changed = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!changed) break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// Edge Lookup Maps
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
interface EdgeTarget {
|
|
108
|
+
node: string;
|
|
109
|
+
port: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Map: targetNodeId → (targetPortId → source) */
|
|
113
|
+
type IncomingMap = Map<string, Map<string, EdgeTarget>>;
|
|
114
|
+
|
|
115
|
+
/** Map: sourceNodeId → (sourcePortId → targets[]) */
|
|
116
|
+
type OutgoingMap = Map<string, Map<string, EdgeTarget[]>>;
|
|
117
|
+
|
|
118
|
+
function buildIncomingMap(edges: GraphEdge[]): IncomingMap {
|
|
119
|
+
const map: IncomingMap = new Map();
|
|
120
|
+
for (const e of edges) {
|
|
121
|
+
if (!map.has(e.targetNode)) map.set(e.targetNode, new Map());
|
|
122
|
+
map.get(e.targetNode)?.set(e.targetPort, { node: e.sourceNode, port: e.sourcePort });
|
|
123
|
+
}
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildOutgoingMap(edges: GraphEdge[]): OutgoingMap {
|
|
128
|
+
const map: OutgoingMap = new Map();
|
|
129
|
+
for (const e of edges) {
|
|
130
|
+
if (!map.has(e.sourceNode)) map.set(e.sourceNode, new Map());
|
|
131
|
+
const portMap = map.get(e.sourceNode);
|
|
132
|
+
if (portMap) {
|
|
133
|
+
if (!portMap.has(e.sourcePort)) portMap.set(e.sourcePort, []);
|
|
134
|
+
portMap.get(e.sourcePort)?.push({ node: e.targetNode, port: e.targetPort });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return map;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// Phase 1: External Type Resolution
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function resolveExternalTypes(
|
|
145
|
+
nodes: GraphNode[],
|
|
146
|
+
resolver: TypeResolver,
|
|
147
|
+
result: PortTypeMap
|
|
148
|
+
): void {
|
|
149
|
+
for (const node of nodes) {
|
|
150
|
+
for (const [portId, port] of Object.entries(node.ports)) {
|
|
151
|
+
if (port.type.kind !== 'resolved') continue;
|
|
152
|
+
|
|
153
|
+
const configValue = node.config?.[port.type.configField] as string | undefined;
|
|
154
|
+
if (!configValue) continue;
|
|
155
|
+
|
|
156
|
+
const resolved = resolver.resolve(port.type.source, configValue);
|
|
157
|
+
if (resolved && isConcrete(resolved)) {
|
|
158
|
+
result.set(portKey(node.id, portId), resolved);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
// Phase 2: Forward Propagation
|
|
166
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function propagateForward(
|
|
169
|
+
node: GraphNode,
|
|
170
|
+
incoming: IncomingMap,
|
|
171
|
+
nodeMap: Map<string, GraphNode>,
|
|
172
|
+
result: PortTypeMap
|
|
173
|
+
): boolean {
|
|
174
|
+
let changed = false;
|
|
175
|
+
const nodeIncoming = incoming.get(node.id);
|
|
176
|
+
if (!nodeIncoming) return false;
|
|
177
|
+
|
|
178
|
+
for (const [inputPortId, port] of Object.entries(node.ports)) {
|
|
179
|
+
if (port.direction !== 'input') continue;
|
|
180
|
+
|
|
181
|
+
const key = portKey(node.id, inputPortId);
|
|
182
|
+
if (result.has(key)) continue; // already resolved
|
|
183
|
+
|
|
184
|
+
const source = nodeIncoming.get(inputPortId);
|
|
185
|
+
if (!source) continue;
|
|
186
|
+
|
|
187
|
+
// Check if the source's declared type or its inferred type is concrete
|
|
188
|
+
const sourceKey = portKey(source.node, source.port);
|
|
189
|
+
const sourceType = result.get(sourceKey);
|
|
190
|
+
|
|
191
|
+
if (sourceType && isConcrete(sourceType) && needsResolution(port.type)) {
|
|
192
|
+
result.set(key, sourceType);
|
|
193
|
+
changed = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return changed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
// Phase 3: Passthrough Resolution
|
|
202
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function resolvePassthrough(node: GraphNode, result: PortTypeMap): boolean {
|
|
205
|
+
let changed = false;
|
|
206
|
+
|
|
207
|
+
for (const [portId, port] of Object.entries(node.ports)) {
|
|
208
|
+
if (port.type.kind !== 'passthrough') continue;
|
|
209
|
+
|
|
210
|
+
const key = portKey(node.id, portId);
|
|
211
|
+
if (result.has(key)) continue;
|
|
212
|
+
|
|
213
|
+
// Look up the referenced input port on the same node
|
|
214
|
+
const sourceInputKey = portKey(node.id, port.type.sourcePortId);
|
|
215
|
+
const resolvedInput = result.get(sourceInputKey);
|
|
216
|
+
|
|
217
|
+
if (resolvedInput && isConcrete(resolvedInput)) {
|
|
218
|
+
result.set(key, resolvedInput);
|
|
219
|
+
changed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return changed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// Phase 4: Backward Propagation
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function propagateBackward(
|
|
231
|
+
node: GraphNode,
|
|
232
|
+
outgoing: OutgoingMap,
|
|
233
|
+
nodeMap: Map<string, GraphNode>,
|
|
234
|
+
result: PortTypeMap
|
|
235
|
+
): boolean {
|
|
236
|
+
let changed = false;
|
|
237
|
+
const nodeOutgoing = outgoing.get(node.id);
|
|
238
|
+
if (!nodeOutgoing) return false;
|
|
239
|
+
|
|
240
|
+
for (const [outputPortId, port] of Object.entries(node.ports)) {
|
|
241
|
+
if (port.direction !== 'output') continue;
|
|
242
|
+
|
|
243
|
+
const key = portKey(node.id, outputPortId);
|
|
244
|
+
if (result.has(key)) continue; // already resolved
|
|
245
|
+
|
|
246
|
+
if (!needsResolution(port.type)) continue;
|
|
247
|
+
|
|
248
|
+
const targets = nodeOutgoing.get(outputPortId);
|
|
249
|
+
if (!targets) continue;
|
|
250
|
+
|
|
251
|
+
// If any connected input has a concrete type, use it
|
|
252
|
+
for (const target of targets) {
|
|
253
|
+
const targetKey = portKey(target.node, target.port);
|
|
254
|
+
const targetType = result.get(targetKey);
|
|
255
|
+
|
|
256
|
+
if (targetType && isConcrete(targetType)) {
|
|
257
|
+
result.set(key, targetType);
|
|
258
|
+
changed = true;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return changed;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// Utility
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function portKey(nodeId: string, portId: string): string {
|
|
272
|
+
return `${nodeId}:${portId}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { portKey };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeDescriptor → JSON Schema conversion.
|
|
3
|
+
*
|
|
4
|
+
* Produces standard JSON Schema (draft 2020-12 compatible) for API consumers
|
|
5
|
+
* that need JSON Schema format (e.g., UI schema rendering, documentation).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TypeDescriptor } from './descriptor';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert a TypeDescriptor to a JSON Schema object.
|
|
12
|
+
*/
|
|
13
|
+
export function toJsonSchema(desc: TypeDescriptor): Record<string, unknown> {
|
|
14
|
+
switch (desc.kind) {
|
|
15
|
+
case 'primitive':
|
|
16
|
+
return { type: desc.type === 'null' ? 'null' : desc.type };
|
|
17
|
+
|
|
18
|
+
case 'literal':
|
|
19
|
+
return { const: desc.value };
|
|
20
|
+
|
|
21
|
+
case 'object': {
|
|
22
|
+
const properties: Record<string, Record<string, unknown>> = {};
|
|
23
|
+
const required: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const [key, field] of Object.entries(desc.fields)) {
|
|
26
|
+
properties[key] = toJsonSchema(field.type);
|
|
27
|
+
if (!field.optional) required.push(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const schema: Record<string, unknown> = { type: 'object', properties };
|
|
31
|
+
if (required.length > 0) schema.required = required;
|
|
32
|
+
return schema;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case 'array':
|
|
36
|
+
return { type: 'array', items: toJsonSchema(desc.element) };
|
|
37
|
+
|
|
38
|
+
case 'tuple':
|
|
39
|
+
return { type: 'array', prefixItems: desc.elements.map(toJsonSchema) };
|
|
40
|
+
|
|
41
|
+
case 'union':
|
|
42
|
+
return { anyOf: desc.variants.map(toJsonSchema) };
|
|
43
|
+
|
|
44
|
+
case 'record':
|
|
45
|
+
return { type: 'object', additionalProperties: toJsonSchema(desc.value) };
|
|
46
|
+
|
|
47
|
+
case 'enum':
|
|
48
|
+
return { enum: [...desc.values] };
|
|
49
|
+
|
|
50
|
+
case 'any':
|
|
51
|
+
return {};
|
|
52
|
+
|
|
53
|
+
case 'unknown':
|
|
54
|
+
return {};
|
|
55
|
+
|
|
56
|
+
case 'generic':
|
|
57
|
+
return { description: `generic<${desc.typeVar}>` };
|
|
58
|
+
|
|
59
|
+
case 'passthrough':
|
|
60
|
+
return { description: `passthrough(${desc.sourcePortId})` };
|
|
61
|
+
|
|
62
|
+
case 'resolved':
|
|
63
|
+
return { description: `$resolve:${desc.source}:${desc.configField}` };
|
|
64
|
+
}
|
|
65
|
+
}
|