@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete — generate completion items from TypeDescriptor.
|
|
3
|
+
*
|
|
4
|
+
* Given a resolved type, produces a flat list of completable paths
|
|
5
|
+
* with their types. Used by the expression editor to offer
|
|
6
|
+
* deep property autocompletion (e.g., inputs.in.name, inputs.in.age).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TypeDescriptor } from './descriptor';
|
|
10
|
+
import { displayType } from './display';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Completion Item
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface CompletionItem {
|
|
17
|
+
/** Short label (e.g., "name") */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Type display string (e.g., "string") */
|
|
20
|
+
type: string;
|
|
21
|
+
/** Full dotted path (e.g., "inputs.in.name") */
|
|
22
|
+
path: string;
|
|
23
|
+
/** Whether this item has children (for nested objects) */
|
|
24
|
+
hasChildren: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Main Entry Point
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get autocomplete items for a type at a given base path.
|
|
35
|
+
*
|
|
36
|
+
* @param desc - The TypeDescriptor to explore
|
|
37
|
+
* @param basePath - The path prefix (e.g., "inputs.in")
|
|
38
|
+
* @param maxDepth - Maximum depth to recurse (default 3)
|
|
39
|
+
* @returns Flat list of completion items
|
|
40
|
+
*/
|
|
41
|
+
export function getCompletions(
|
|
42
|
+
desc: TypeDescriptor,
|
|
43
|
+
basePath: string,
|
|
44
|
+
maxDepth = DEFAULT_MAX_DEPTH
|
|
45
|
+
): CompletionItem[] {
|
|
46
|
+
const items: CompletionItem[] = [];
|
|
47
|
+
|
|
48
|
+
// Add the root item itself
|
|
49
|
+
items.push({
|
|
50
|
+
label: lastSegment(basePath),
|
|
51
|
+
type: displayType(desc),
|
|
52
|
+
path: basePath,
|
|
53
|
+
hasChildren: hasNestedFields(desc),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Recurse into fields
|
|
57
|
+
if (maxDepth > 0) {
|
|
58
|
+
collectFields(desc, basePath, maxDepth, items);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Internal
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function collectFields(
|
|
69
|
+
desc: TypeDescriptor,
|
|
70
|
+
basePath: string,
|
|
71
|
+
depth: number,
|
|
72
|
+
items: CompletionItem[]
|
|
73
|
+
): void {
|
|
74
|
+
if (depth <= 0) return;
|
|
75
|
+
|
|
76
|
+
switch (desc.kind) {
|
|
77
|
+
case 'object': {
|
|
78
|
+
for (const [fieldName, field] of Object.entries(desc.fields)) {
|
|
79
|
+
const path = `${basePath}.${fieldName}`;
|
|
80
|
+
items.push({
|
|
81
|
+
label: fieldName,
|
|
82
|
+
type: displayType(field.type),
|
|
83
|
+
path,
|
|
84
|
+
hasChildren: hasNestedFields(field.type),
|
|
85
|
+
});
|
|
86
|
+
collectFields(field.type, path, depth - 1, items);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'array': {
|
|
92
|
+
// Offer [n] access for element type
|
|
93
|
+
const path = `${basePath}[n]`;
|
|
94
|
+
items.push({
|
|
95
|
+
label: '[n]',
|
|
96
|
+
type: displayType(desc.element),
|
|
97
|
+
path,
|
|
98
|
+
hasChildren: hasNestedFields(desc.element),
|
|
99
|
+
});
|
|
100
|
+
collectFields(desc.element, path, depth - 1, items);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'record': {
|
|
105
|
+
// Offer [key] access for value type
|
|
106
|
+
const path = `${basePath}[key]`;
|
|
107
|
+
items.push({
|
|
108
|
+
label: '[key]',
|
|
109
|
+
type: displayType(desc.value),
|
|
110
|
+
path,
|
|
111
|
+
hasChildren: hasNestedFields(desc.value),
|
|
112
|
+
});
|
|
113
|
+
collectFields(desc.value, path, depth - 1, items);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'union': {
|
|
118
|
+
// For unions, expose fields that are common across all object variants
|
|
119
|
+
const commonFields = getCommonObjectFields(desc.variants);
|
|
120
|
+
if (commonFields) {
|
|
121
|
+
for (const [fieldName, fieldType] of Object.entries(commonFields)) {
|
|
122
|
+
const path = `${basePath}.${fieldName}`;
|
|
123
|
+
items.push({
|
|
124
|
+
label: fieldName,
|
|
125
|
+
type: displayType(fieldType),
|
|
126
|
+
path,
|
|
127
|
+
hasChildren: hasNestedFields(fieldType),
|
|
128
|
+
});
|
|
129
|
+
collectFields(fieldType, path, depth - 1, items);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
// Primitives, literals, enums, etc. have no nested fields
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hasNestedFields(desc: TypeDescriptor): boolean {
|
|
139
|
+
return desc.kind === 'object' || desc.kind === 'array' || desc.kind === 'record';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function lastSegment(path: string): string {
|
|
143
|
+
const dot = path.lastIndexOf('.');
|
|
144
|
+
return dot >= 0 ? path.slice(dot + 1) : path;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* For a union of object types, find fields that exist in ALL variants.
|
|
149
|
+
* Returns a map of fieldName → TypeDescriptor (using the first variant's type).
|
|
150
|
+
*/
|
|
151
|
+
function getCommonObjectFields(
|
|
152
|
+
variants: readonly TypeDescriptor[]
|
|
153
|
+
): Record<string, TypeDescriptor> | null {
|
|
154
|
+
const objectVariants = variants.filter(
|
|
155
|
+
(v): v is Extract<TypeDescriptor, { kind: 'object' }> => v.kind === 'object'
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (objectVariants.length === 0 || objectVariants.length !== variants.length) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Start with fields from the first variant, keep only those present in all
|
|
163
|
+
const first = objectVariants[0] as Extract<TypeDescriptor, { kind: 'object' }>;
|
|
164
|
+
const common: Record<string, TypeDescriptor> = {};
|
|
165
|
+
|
|
166
|
+
for (const [fieldName, field] of Object.entries(first.fields)) {
|
|
167
|
+
const presentInAll = objectVariants.every((v) => fieldName in v.fields);
|
|
168
|
+
if (presentInAll) {
|
|
169
|
+
common[fieldName] = field.type;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return Object.keys(common).length > 0 ? common : null;
|
|
174
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural Type Compatibility
|
|
3
|
+
*
|
|
4
|
+
* Checks if an output type can flow into an input type.
|
|
5
|
+
* Replaces both arePortTypesCompatible (string-based) and isSchemaCompatible (Zod-based).
|
|
6
|
+
*
|
|
7
|
+
* Rules (in priority order):
|
|
8
|
+
* 1. any/unknown/generic inputs accept everything
|
|
9
|
+
* 2. any/unknown/generic outputs are accepted by everything
|
|
10
|
+
* 3. Exact kind + structural match
|
|
11
|
+
* 4. Numeric equivalence (number/integer/float/double all compatible)
|
|
12
|
+
* 5. Widening: number/boolean → string
|
|
13
|
+
* 6. Object structural subtyping
|
|
14
|
+
* 7. Union rules (output union: all must satisfy; input union: one must match)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TypeDescriptor } from './descriptor';
|
|
18
|
+
|
|
19
|
+
const NUMERIC_TYPES = new Set(['number', 'integer', 'float', 'double']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if an output type is compatible with an input type.
|
|
23
|
+
* Returns true if data of `output` type can safely flow into a port expecting `input` type.
|
|
24
|
+
*/
|
|
25
|
+
export function isCompatible(output: TypeDescriptor, input: TypeDescriptor): boolean {
|
|
26
|
+
// Wildcards accept/produce anything
|
|
27
|
+
if (isAcceptAll(input) || isAcceptAll(output)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Passthrough/resolved are treated as wildcards (they need resolution first)
|
|
32
|
+
if (input.kind === 'passthrough' || input.kind === 'resolved') return true;
|
|
33
|
+
if (output.kind === 'passthrough' || output.kind === 'resolved') return true;
|
|
34
|
+
|
|
35
|
+
// Exact same kind — dispatch to specific checker
|
|
36
|
+
if (output.kind === input.kind) {
|
|
37
|
+
return checkSameKind(output, input);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Union handling
|
|
41
|
+
if (input.kind === 'union') {
|
|
42
|
+
// Output must satisfy at least one variant
|
|
43
|
+
return input.variants.some((variant) => isCompatible(output, variant));
|
|
44
|
+
}
|
|
45
|
+
if (output.kind === 'union') {
|
|
46
|
+
// All output variants must satisfy input
|
|
47
|
+
return output.variants.every((variant) => isCompatible(variant, input));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Enum → primitive widening
|
|
51
|
+
if (output.kind === 'enum' && input.kind === 'primitive') {
|
|
52
|
+
return isEnumCompatibleWithPrimitive(output.values, input.type);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Literal → primitive widening
|
|
56
|
+
if (output.kind === 'literal' && input.kind === 'primitive') {
|
|
57
|
+
return isLiteralCompatibleWithPrimitive(output.value, input.type);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Primitive widening: number/boolean → string
|
|
61
|
+
if (output.kind === 'primitive' && input.kind === 'primitive') {
|
|
62
|
+
return isPrimitiveCompatible(output.type, input.type);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Record → object: a record can satisfy an object if value type is compatible with all fields
|
|
66
|
+
if (output.kind === 'record' && input.kind === 'object') {
|
|
67
|
+
return Object.values(input.fields).every(
|
|
68
|
+
(field) => field.optional || isCompatible(output.value, field.type)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Object → record: an object can produce a record
|
|
73
|
+
if (output.kind === 'object' && input.kind === 'record') {
|
|
74
|
+
return Object.values(output.fields).every((field) => isCompatible(field.type, input.value));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Internal helpers
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function isAcceptAll(desc: TypeDescriptor): boolean {
|
|
85
|
+
return desc.kind === 'any' || desc.kind === 'unknown' || desc.kind === 'generic';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkSameKind(output: TypeDescriptor, input: TypeDescriptor): boolean {
|
|
89
|
+
switch (output.kind) {
|
|
90
|
+
case 'primitive':
|
|
91
|
+
return isPrimitiveCompatible(
|
|
92
|
+
output.type,
|
|
93
|
+
(input as Extract<TypeDescriptor, { kind: 'primitive' }>).type
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
case 'literal': {
|
|
97
|
+
const inputLit = input as Extract<TypeDescriptor, { kind: 'literal' }>;
|
|
98
|
+
return output.value === inputLit.value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'object': {
|
|
102
|
+
const inputObj = input as Extract<TypeDescriptor, { kind: 'object' }>;
|
|
103
|
+
return isObjectCompatible(output.fields, inputObj.fields);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'array': {
|
|
107
|
+
const inputArr = input as Extract<TypeDescriptor, { kind: 'array' }>;
|
|
108
|
+
return isCompatible(output.element, inputArr.element);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case 'tuple': {
|
|
112
|
+
const inputTuple = input as Extract<TypeDescriptor, { kind: 'tuple' }>;
|
|
113
|
+
if (output.elements.length !== inputTuple.elements.length) return false;
|
|
114
|
+
return output.elements.every((el, i) => {
|
|
115
|
+
const inputEl = inputTuple.elements[i];
|
|
116
|
+
return inputEl !== undefined && isCompatible(el, inputEl);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'union': {
|
|
121
|
+
const inputUnion = input as Extract<TypeDescriptor, { kind: 'union' }>;
|
|
122
|
+
// All output variants must satisfy at least one input variant
|
|
123
|
+
return output.variants.every((outV) =>
|
|
124
|
+
inputUnion.variants.some((inV) => isCompatible(outV, inV))
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'record': {
|
|
129
|
+
const inputRec = input as Extract<TypeDescriptor, { kind: 'record' }>;
|
|
130
|
+
return isCompatible(output.value, inputRec.value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'enum': {
|
|
134
|
+
const inputEnum = input as Extract<TypeDescriptor, { kind: 'enum' }>;
|
|
135
|
+
// All output values must be in input values
|
|
136
|
+
return output.values.every((v) => inputEnum.values.includes(v));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
default:
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPrimitiveCompatible(outputType: string, inputType: string): boolean {
|
|
145
|
+
if (outputType === inputType) return true;
|
|
146
|
+
|
|
147
|
+
// Numeric equivalence
|
|
148
|
+
if (NUMERIC_TYPES.has(outputType) && NUMERIC_TYPES.has(inputType)) return true;
|
|
149
|
+
|
|
150
|
+
// Widening: number/boolean → string
|
|
151
|
+
if (inputType === 'string' && (NUMERIC_TYPES.has(outputType) || outputType === 'boolean')) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isObjectCompatible(
|
|
159
|
+
outputFields: Record<string, { type: TypeDescriptor; optional: boolean }>,
|
|
160
|
+
inputFields: Record<string, { type: TypeDescriptor; optional: boolean }>
|
|
161
|
+
): boolean {
|
|
162
|
+
// All required input fields must exist in output with compatible types
|
|
163
|
+
for (const [key, inputField] of Object.entries(inputFields)) {
|
|
164
|
+
const outputField = outputFields[key];
|
|
165
|
+
|
|
166
|
+
if (!outputField) {
|
|
167
|
+
// Output doesn't have this field
|
|
168
|
+
if (!inputField.optional) return false;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check field type compatibility
|
|
173
|
+
if (!isCompatible(outputField.type, inputField.type)) return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isEnumCompatibleWithPrimitive(
|
|
180
|
+
values: readonly (string | number)[],
|
|
181
|
+
primitiveType: string
|
|
182
|
+
): boolean {
|
|
183
|
+
if (primitiveType === 'string') {
|
|
184
|
+
return values.every((v) => typeof v === 'string' || typeof v === 'number');
|
|
185
|
+
}
|
|
186
|
+
if (NUMERIC_TYPES.has(primitiveType)) {
|
|
187
|
+
return values.every((v) => typeof v === 'number');
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isLiteralCompatibleWithPrimitive(
|
|
193
|
+
value: string | number | boolean,
|
|
194
|
+
primitiveType: string
|
|
195
|
+
): boolean {
|
|
196
|
+
if (primitiveType === 'string') return true; // any literal can be stringified
|
|
197
|
+
if (primitiveType === 'boolean') return typeof value === 'boolean';
|
|
198
|
+
if (NUMERIC_TYPES.has(primitiveType)) return typeof value === 'number';
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeDescriptor — serializable, structural type representation.
|
|
3
|
+
*
|
|
4
|
+
* This is the single source of truth for port types across the entire system.
|
|
5
|
+
* Both backend and frontend work with the same representation.
|
|
6
|
+
* JSON-serializable, works over IPC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Type Descriptor
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type PrimitiveType = 'string' | 'number' | 'boolean' | 'null';
|
|
14
|
+
|
|
15
|
+
export type TypeDescriptor =
|
|
16
|
+
| { readonly kind: 'primitive'; readonly type: PrimitiveType }
|
|
17
|
+
| { readonly kind: 'literal'; readonly value: string | number | boolean }
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'object';
|
|
20
|
+
readonly fields: Record<string, { readonly type: TypeDescriptor; readonly optional: boolean }>;
|
|
21
|
+
}
|
|
22
|
+
| { readonly kind: 'array'; readonly element: TypeDescriptor }
|
|
23
|
+
| { readonly kind: 'tuple'; readonly elements: readonly TypeDescriptor[] }
|
|
24
|
+
| { readonly kind: 'union'; readonly variants: readonly TypeDescriptor[] }
|
|
25
|
+
| { readonly kind: 'record'; readonly value: TypeDescriptor }
|
|
26
|
+
| { readonly kind: 'enum'; readonly values: readonly (string | number)[] }
|
|
27
|
+
| { readonly kind: 'any' }
|
|
28
|
+
| { readonly kind: 'unknown' }
|
|
29
|
+
| { readonly kind: 'generic'; readonly typeVar: string }
|
|
30
|
+
| { readonly kind: 'passthrough'; readonly sourcePortId: string }
|
|
31
|
+
| { readonly kind: 'resolved'; readonly source: string; readonly configField: string };
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Constructors — shorthand factories for readable code
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const T = {
|
|
38
|
+
string: { kind: 'primitive', type: 'string' } as TypeDescriptor,
|
|
39
|
+
number: { kind: 'primitive', type: 'number' } as TypeDescriptor,
|
|
40
|
+
boolean: { kind: 'primitive', type: 'boolean' } as TypeDescriptor,
|
|
41
|
+
null: { kind: 'primitive', type: 'null' } as TypeDescriptor,
|
|
42
|
+
any: { kind: 'any' } as TypeDescriptor,
|
|
43
|
+
unknown: { kind: 'unknown' } as TypeDescriptor,
|
|
44
|
+
|
|
45
|
+
literal(value: string | number | boolean): TypeDescriptor {
|
|
46
|
+
return { kind: 'literal', value };
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
object(fields: Record<string, { type: TypeDescriptor; optional: boolean }>): TypeDescriptor {
|
|
50
|
+
return { kind: 'object', fields };
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** Shorthand: all fields required */
|
|
54
|
+
obj(fields: Record<string, TypeDescriptor>): TypeDescriptor {
|
|
55
|
+
const mapped: Record<string, { type: TypeDescriptor; optional: boolean }> = {};
|
|
56
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
57
|
+
mapped[k] = { type: v, optional: false };
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'object', fields: mapped };
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
array(element: TypeDescriptor): TypeDescriptor {
|
|
63
|
+
return { kind: 'array', element };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
tuple(elements: TypeDescriptor[]): TypeDescriptor {
|
|
67
|
+
return { kind: 'tuple', elements };
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
union(variants: TypeDescriptor[]): TypeDescriptor {
|
|
71
|
+
return { kind: 'union', variants };
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
record(value: TypeDescriptor): TypeDescriptor {
|
|
75
|
+
return { kind: 'record', value };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
enum(values: (string | number)[]): TypeDescriptor {
|
|
79
|
+
return { kind: 'enum', values };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
generic(typeVar = 'T'): TypeDescriptor {
|
|
83
|
+
return { kind: 'generic', typeVar };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
passthrough(sourcePortId: string): TypeDescriptor {
|
|
87
|
+
return { kind: 'passthrough', sourcePortId };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
resolved(source: string, configField: string): TypeDescriptor {
|
|
91
|
+
return { kind: 'resolved', source, configField };
|
|
92
|
+
},
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Type Guards
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** Returns true if the type is concrete (not generic, passthrough, or resolved) */
|
|
100
|
+
export function isConcrete(desc: TypeDescriptor): boolean {
|
|
101
|
+
return desc.kind !== 'generic' && desc.kind !== 'passthrough' && desc.kind !== 'resolved';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Returns true if the type accepts any value (any, unknown, or generic) */
|
|
105
|
+
export function isWildcard(desc: TypeDescriptor): boolean {
|
|
106
|
+
return desc.kind === 'any' || desc.kind === 'unknown' || desc.kind === 'generic';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Returns true if the type needs resolution before it can be used */
|
|
110
|
+
export function needsResolution(desc: TypeDescriptor): boolean {
|
|
111
|
+
return desc.kind === 'generic' || desc.kind === 'passthrough' || desc.kind === 'resolved';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Parsing — convert legacy typeName strings to TypeDescriptor
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** Parse a legacy typeName string (e.g. "string", "generic<T>", "passthrough(in)") into a TypeDescriptor */
|
|
119
|
+
export function parseTypeName(typeName?: string): TypeDescriptor {
|
|
120
|
+
if (!typeName) return T.generic();
|
|
121
|
+
|
|
122
|
+
if (typeName.startsWith('generic')) return T.generic(typeName.match(/<(\w+)>/)?.[1] ?? 'T');
|
|
123
|
+
if (typeName.startsWith('__passthrough:')) return T.passthrough(typeName.slice('__passthrough:'.length));
|
|
124
|
+
if (typeName.startsWith('passthrough')) return T.passthrough(typeName.match(/\((\w+)\)/)?.[1] ?? 'in');
|
|
125
|
+
if (typeName.startsWith('$resolve:')) {
|
|
126
|
+
const parts = typeName.slice('$resolve:'.length).split(':');
|
|
127
|
+
return T.resolved(parts[0] ?? '', parts[1] ?? '');
|
|
128
|
+
}
|
|
129
|
+
if (typeName === 'unknown' || typeName === 'any') return T.unknown;
|
|
130
|
+
if (typeName === 'string') return T.string;
|
|
131
|
+
if (typeName === 'number' || typeName === 'integer') return T.number;
|
|
132
|
+
if (typeName === 'boolean') return T.boolean;
|
|
133
|
+
if (typeName === 'null') return T.null;
|
|
134
|
+
if (typeName === 'array') return T.array(T.unknown);
|
|
135
|
+
if (typeName.endsWith('[]')) return T.array(parseTypeName(typeName.slice(0, -2)));
|
|
136
|
+
|
|
137
|
+
return T.unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Extract a TypeDescriptor from a port, preferring the structured `type` field over `typeName` */
|
|
141
|
+
export function parsePortType(port: { typeName?: string; type?: Record<string, unknown> }): TypeDescriptor {
|
|
142
|
+
if (port.type && typeof port.type === 'object' && 'kind' in port.type) {
|
|
143
|
+
return port.type as unknown as TypeDescriptor;
|
|
144
|
+
}
|
|
145
|
+
return parseTypeName(port.typeName);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Infer a TypeDescriptor from a runtime JSON value */
|
|
149
|
+
export function inferType(data: unknown): TypeDescriptor {
|
|
150
|
+
if (data === null) return T.null;
|
|
151
|
+
if (typeof data === 'string') return T.string;
|
|
152
|
+
if (typeof data === 'number') return T.number;
|
|
153
|
+
if (typeof data === 'boolean') return T.boolean;
|
|
154
|
+
if (Array.isArray(data)) return T.array(T.unknown);
|
|
155
|
+
if (typeof data === 'object') return T.record(T.unknown);
|
|
156
|
+
return T.unknown;
|
|
157
|
+
}
|
package/src/display.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display — human-readable type names from TypeDescriptor.
|
|
3
|
+
*
|
|
4
|
+
* Produces TypeScript-like type strings:
|
|
5
|
+
* - "string", "number", "boolean", "null"
|
|
6
|
+
* - "{name: string, age: number}"
|
|
7
|
+
* - "number[]"
|
|
8
|
+
* - "string | number"
|
|
9
|
+
* - "generic<T>"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TypeDescriptor } from './descriptor';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a TypeDescriptor to a human-readable type name string.
|
|
16
|
+
*/
|
|
17
|
+
export function displayType(desc: TypeDescriptor): string {
|
|
18
|
+
switch (desc.kind) {
|
|
19
|
+
case 'primitive':
|
|
20
|
+
return desc.type;
|
|
21
|
+
|
|
22
|
+
case 'literal':
|
|
23
|
+
return typeof desc.value === 'string' ? `"${desc.value}"` : String(desc.value);
|
|
24
|
+
|
|
25
|
+
case 'object': {
|
|
26
|
+
const entries = Object.entries(desc.fields);
|
|
27
|
+
if (entries.length === 0) return '{}';
|
|
28
|
+
const fields = entries
|
|
29
|
+
.map(([k, v]) => `${k}${v.optional ? '?' : ''}: ${displayType(v.type)}`)
|
|
30
|
+
.join(', ');
|
|
31
|
+
return `{${fields}}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
case 'array': {
|
|
35
|
+
const el = displayType(desc.element);
|
|
36
|
+
// Wrap union types in parens for readability: (string | number)[]
|
|
37
|
+
const needsParens = desc.element.kind === 'union';
|
|
38
|
+
return needsParens ? `(${el})[]` : `${el}[]`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case 'tuple': {
|
|
42
|
+
const elements = desc.elements.map(displayType).join(', ');
|
|
43
|
+
return `[${elements}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case 'union':
|
|
47
|
+
return desc.variants.map(displayType).join(' | ');
|
|
48
|
+
|
|
49
|
+
case 'record': {
|
|
50
|
+
return `Record<string, ${displayType(desc.value)}>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'enum':
|
|
54
|
+
return desc.values.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ');
|
|
55
|
+
|
|
56
|
+
case 'any':
|
|
57
|
+
return 'any';
|
|
58
|
+
|
|
59
|
+
case 'unknown':
|
|
60
|
+
return 'unknown';
|
|
61
|
+
|
|
62
|
+
case 'generic':
|
|
63
|
+
return `generic<${desc.typeVar}>`;
|
|
64
|
+
|
|
65
|
+
case 'passthrough':
|
|
66
|
+
return `passthrough(${desc.sourcePortId})`;
|
|
67
|
+
|
|
68
|
+
case 'resolved':
|
|
69
|
+
return `$resolve:${desc.source}:${desc.configField}`;
|
|
70
|
+
}
|
|
71
|
+
}
|