@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,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
+ }