@tscomfy/codegen 0.0.0-stage → 0.1.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -2
  3. package/dist/doc.d.ts +78 -0
  4. package/dist/doc.d.ts.map +1 -0
  5. package/dist/doc.js +335 -0
  6. package/dist/doc.js.map +1 -0
  7. package/dist/emit-schema-module.d.ts +11 -0
  8. package/dist/emit-schema-module.d.ts.map +1 -0
  9. package/dist/emit-schema-module.js +39 -0
  10. package/dist/emit-schema-module.js.map +1 -0
  11. package/dist/emit-types.d.ts +15 -0
  12. package/dist/emit-types.d.ts.map +1 -0
  13. package/dist/emit-types.js +718 -0
  14. package/dist/emit-types.js.map +1 -0
  15. package/dist/export-name.d.ts +16 -0
  16. package/dist/export-name.d.ts.map +1 -0
  17. package/dist/export-name.js +43 -0
  18. package/dist/export-name.js.map +1 -0
  19. package/dist/expressions.d.ts +51 -0
  20. package/dist/expressions.d.ts.map +1 -0
  21. package/dist/expressions.js +147 -0
  22. package/dist/expressions.js.map +1 -0
  23. package/dist/generate.d.ts +35 -0
  24. package/dist/generate.d.ts.map +1 -0
  25. package/dist/generate.js +30 -0
  26. package/dist/generate.js.map +1 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/names.d.ts +36 -0
  32. package/dist/names.d.ts.map +1 -0
  33. package/dist/names.js +81 -0
  34. package/dist/names.js.map +1 -0
  35. package/dist/rules.d.ts +23 -0
  36. package/dist/rules.d.ts.map +1 -0
  37. package/dist/rules.js +82 -0
  38. package/dist/rules.js.map +1 -0
  39. package/dist/transpile.d.ts +40 -0
  40. package/dist/transpile.d.ts.map +1 -0
  41. package/dist/transpile.js +531 -0
  42. package/dist/transpile.js.map +1 -0
  43. package/dist/typing.d.ts +18 -0
  44. package/dist/typing.d.ts.map +1 -0
  45. package/dist/typing.js +387 -0
  46. package/dist/typing.js.map +1 -0
  47. package/dist/values.d.ts +15 -0
  48. package/dist/values.d.ts.map +1 -0
  49. package/dist/values.js +22 -0
  50. package/dist/values.js.map +1 -0
  51. package/package.json +46 -4
  52. package/src/doc.ts +390 -0
  53. package/src/emit-schema-module.ts +47 -0
  54. package/src/emit-types.ts +880 -0
  55. package/src/export-name.ts +51 -0
  56. package/src/expressions.ts +201 -0
  57. package/src/generate.ts +59 -0
  58. package/src/index.ts +22 -0
  59. package/src/names.ts +89 -0
  60. package/src/rules.ts +86 -0
  61. package/src/transpile.ts +639 -0
  62. package/src/typing.ts +456 -0
  63. package/src/values.ts +28 -0
package/src/typing.ts ADDED
@@ -0,0 +1,456 @@
1
+ // Whether the generated types can express a node (docs/plan.md §10), decided by the rules
2
+ // `emit-types.ts` types inputs with (§6.2, §6.3): link atoms per output, legacy combos taking `*`,
3
+ // socketless widgets, MatchType's inferring position and parameter defaults, Autogrow and
4
+ // DynamicGroup bounds, and the forced row field.
5
+
6
+ import {
7
+ type GraphNode,
8
+ type InputDef,
9
+ type InputValue,
10
+ isOutputRef,
11
+ type NodeDef,
12
+ type NodeId,
13
+ type OutputDef,
14
+ type SocketType,
15
+ sendsInputValue,
16
+ type Workflow,
17
+ } from "@tscomfy/core";
18
+ import { forcedRowField, isObjectMember, isRequired } from "./rules.ts";
19
+ import { isList, isPlainObject } from "./values.ts";
20
+
21
+ /** How a node is written: `typed` as `n.Class(…)`, `raw` as `raw(…)` with its wire keys. */
22
+ export type WriteMode = "typed" | "raw";
23
+
24
+ /**
25
+ * Whether the generated types can express a node's inputs, by the rules `emit-types.ts` types
26
+ * them with. Returns why not, or `undefined`.
27
+ */
28
+ export class Typing {
29
+ readonly #workflow: Workflow;
30
+ /** How each node is written: `undefined` while undecided, or for a node left out. */
31
+ readonly #mode: (id: NodeId) => WriteMode | undefined;
32
+ /** Resolved MatchType parameters by node and template. */
33
+ readonly #parameters = new Map<string, Atom>();
34
+
35
+ constructor(workflow: Workflow, mode: (id: NodeId) => WriteMode | undefined) {
36
+ this.#workflow = workflow;
37
+ this.#mode = mode;
38
+ }
39
+
40
+ /**
41
+ * Why the types can't express a node, or `undefined` when they can. Nodes are asked in
42
+ * dependency order, so their sources are decided; a typed node's MatchType parameters are then
43
+ * resolved at once, so later nodes look them up rather than recurse through long chains.
44
+ */
45
+ problem(node: GraphNode, definition: NodeDef): string | undefined {
46
+ const scope: Scope = { node, definition };
47
+ const problem = this.#inputs(scope, definition.inputs, node.inputs, undefined, node.classType);
48
+ if (problem === undefined) {
49
+ for (const output of definition.outputs) {
50
+ if (output.matchTemplate !== undefined) {
51
+ this.#parameter(node, definition, output.matchTemplate);
52
+ }
53
+ }
54
+ }
55
+ return problem;
56
+ }
57
+
58
+ /**
59
+ * An inputs interface: no unknown keys, every required member, every value fitting. `forced`
60
+ * is a DynamicGroup row's extra required field.
61
+ */
62
+ #inputs(
63
+ scope: Scope,
64
+ definitions: readonly InputDef[],
65
+ values: { readonly [name: string]: InputValue },
66
+ forced: InputDef | undefined,
67
+ where: string,
68
+ ): string | undefined {
69
+ const members = definitions.filter(sendsInputValue);
70
+ const names = new Set(members.map((input) => input.name));
71
+ const excess = Object.keys(values).find((key) => !names.has(key));
72
+ if (excess !== undefined) return `${where} has no input ${excess}`;
73
+ for (const input of members) {
74
+ const value = Object.hasOwn(values, input.name) ? values[input.name] : undefined;
75
+ if (value === undefined) {
76
+ if (isRequired(input) || input === forced) return `${where} needs ${input.name}`;
77
+ continue;
78
+ }
79
+ const problem = this.#input(scope, input, value, `${where} › ${input.name}`);
80
+ if (problem !== undefined) return problem;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ /** One input's value; `slot` is the index within an Autogrow, for its template. */
86
+ #input(
87
+ scope: Scope,
88
+ input: InputDef,
89
+ value: InputValue,
90
+ where: string,
91
+ slot?: number,
92
+ ): string | undefined {
93
+ switch (input.kind) {
94
+ case "unknown":
95
+ return undefined;
96
+ case "socket":
97
+ return this.#link(value, linkAccepts(input.type), where);
98
+ case "int":
99
+ return typeof value === "number" || typeof value === "bigint"
100
+ ? undefined
101
+ : this.#widgetLink(input.type, input.socketless, value, where, "an integer");
102
+ case "float":
103
+ return typeof value === "number"
104
+ ? undefined
105
+ : this.#widgetLink(input.type, input.socketless, value, where, "a number");
106
+ case "string":
107
+ return typeof value === "string"
108
+ ? undefined
109
+ : this.#widgetLink(input.type, input.socketless, value, where, "a string");
110
+ case "boolean":
111
+ return typeof value === "boolean"
112
+ ? undefined
113
+ : this.#widgetLink(input.type, input.socketless, value, where, "a boolean");
114
+ case "value":
115
+ return isOutputRef(value)
116
+ ? this.#widgetLink(input.type, input.socketless, value, where, "JSON")
117
+ : undefined;
118
+ case "combo":
119
+ return this.#combo(input, value, where);
120
+ case "matchType":
121
+ return this.#match(scope, input, slot, value, where);
122
+ case "autogrow":
123
+ return this.#autogrow(scope, input, value, where);
124
+ case "dynamicCombo": {
125
+ if (typeof value === "string") {
126
+ const option = input.options.find((each) => each.key === value);
127
+ if (option === undefined) return `${where} has no option ${value}`;
128
+ // An option whose inputs may all be left out can be given by its key alone.
129
+ return option.inputs.every((child) => !isRequired(child))
130
+ ? undefined
131
+ : `${where} option ${value} needs its inputs`;
132
+ }
133
+ if (!isPlainObject(value) || typeof value["option"] !== "string") {
134
+ return `${where} takes an option`;
135
+ }
136
+ const option = input.options.find((each) => each.key === value["option"]);
137
+ if (option === undefined || option.inputs.length === 0) {
138
+ return `${where} has no option ${value["option"]} with inputs`;
139
+ }
140
+ if (Object.keys(value).some((key) => key !== "option" && key !== "inputs")) {
141
+ return `${where} has keys besides option and inputs`;
142
+ }
143
+ const children = value["inputs"] ?? {};
144
+ if (!isPlainObject(children)) return `${where} inputs aren't an object`;
145
+ return this.#inputs(scope, option.inputs, children, undefined, where);
146
+ }
147
+ case "dynamicGroup": {
148
+ if (!isList(value)) return `${where} takes a list of rows`;
149
+ if (value.length < input.min || value.length > input.max) {
150
+ return `${where} has ${String(value.length)} rows, outside ${String(input.min)} to ${String(input.max)}`;
151
+ }
152
+ const forced = forcedRowField(input.fields);
153
+ for (const row of value) {
154
+ if (!isPlainObject(row)) return `${where} rows are objects`;
155
+ const problem = this.#inputs(scope, input.fields, row, forced, where);
156
+ if (problem !== undefined) return problem;
157
+ }
158
+ return undefined;
159
+ }
160
+ }
161
+ }
162
+
163
+ #widgetLink(
164
+ type: SocketType,
165
+ socketless: boolean,
166
+ value: InputValue,
167
+ where: string,
168
+ expected: string,
169
+ ): string | undefined {
170
+ if (!isOutputRef(value)) return `${where} takes ${expected}`;
171
+ if (socketless) return `${where} has no socket`;
172
+ return this.#link(value, linkAccepts(type), where);
173
+ }
174
+
175
+ #link(value: InputValue, accepts: (atom: Atom) => boolean, where: string): string | undefined {
176
+ if (!isOutputRef(value)) return `${where} takes a link`;
177
+ const atom = this.#atom(value.node, value.slot);
178
+ if (atom === undefined) return `${where} links to an output that doesn't exist`;
179
+ return accepts(atom) ? undefined : `${where} can't take ${describe(atom)}`;
180
+ }
181
+
182
+ #combo(
183
+ input: Extract<InputDef, { kind: "combo" }>,
184
+ value: InputValue,
185
+ where: string,
186
+ ): string | undefined {
187
+ if (isOutputRef(value)) {
188
+ if (input.socketless) return `${where} has no socket`;
189
+ // A legacy combo takes `*` links only (§5.2); the COMBO form takes its type or option lists.
190
+ return input.form === "legacy"
191
+ ? this.#link(value, (atom) => atom.kind === "star", where)
192
+ : this.#link(value, linkAccepts(input.type), where);
193
+ }
194
+ const open = input.upload || input.remote;
195
+ const fits = (option: InputValue): boolean =>
196
+ input.options.length === 0
197
+ ? typeof option === "string"
198
+ : (open && typeof option === "string") || input.options.some((each) => each === option);
199
+ if (input.multiselect) {
200
+ return isList(value) && value.every(fits)
201
+ ? undefined
202
+ : `${where} takes a list of its options`;
203
+ }
204
+ return fits(value) ? undefined : `${where} isn't one of its options`;
205
+ }
206
+
207
+ #autogrow(
208
+ scope: Scope,
209
+ input: Extract<InputDef, { kind: "autogrow" }>,
210
+ value: InputValue,
211
+ where: string,
212
+ ): string | undefined {
213
+ const required = input.template.required ? Math.min(input.min, input.slots.length) : 0;
214
+ if (input.form === "prefix") {
215
+ if (!isList(value)) return `${where} takes a list`;
216
+ const items = value;
217
+ if (items.length < required || items.length > input.slots.length) {
218
+ return `${where} has ${String(items.length)} slots, outside ${String(required)} to ${String(input.slots.length)}`;
219
+ }
220
+ for (const [index, item] of items.entries()) {
221
+ const problem = this.#input(scope, input.template, item, where, index);
222
+ if (problem !== undefined) return problem;
223
+ }
224
+ return undefined;
225
+ }
226
+ if (!isPlainObject(value)) return `${where} takes slots by name`;
227
+ const excess = Object.keys(value).find((key) => !input.slots.includes(key));
228
+ if (excess !== undefined) return `${where} has no slot ${excess}`;
229
+ for (const [index, slot] of input.slots.entries()) {
230
+ const item = Object.hasOwn(value, slot) ? value[slot] : undefined;
231
+ if (item === undefined) {
232
+ if (index < required || isObjectMember(slot)) return `${where} needs ${slot}`;
233
+ continue;
234
+ }
235
+ const problem = this.#input(scope, input.template, item, where, index);
236
+ if (problem !== undefined) return problem;
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ /**
242
+ * A MatchType position. The template's inferring position (the first one typed, §6.3) takes
243
+ * `Out<T | "*">` with `T` within its allowed types; the others `Match<NoInfer<T>>`.
244
+ */
245
+ #match(
246
+ scope: Scope,
247
+ input: Extract<InputDef, { kind: "matchType" }>,
248
+ slot: number | undefined,
249
+ value: InputValue,
250
+ where: string,
251
+ ): string | undefined {
252
+ const { templateId } = input;
253
+ const position = inferringPosition(scope.definition, templateId);
254
+ if (position?.input === input && (slot === undefined || slot === 0)) {
255
+ const constraint = allowedAtom(input.allowed);
256
+ return this.#link(value, (atom) => atom.kind === "star" || within(atom, constraint), where);
257
+ }
258
+ const parameter = this.#parameter(scope.node, scope.definition, templateId);
259
+ return this.#link(value, (atom) => matchAccepts(parameter, atom), where);
260
+ }
261
+
262
+ /**
263
+ * What a link from (`node`, `slot`) is typed as in builder code (emit-types `#outputAtom`):
264
+ * `undefined` when the node or slot doesn't exist. A raw node's outputs are `Out<"*">`.
265
+ */
266
+ #atom(id: NodeId, slot: number): Atom | undefined {
267
+ const node = this.#workflow.graph.nodes.get(id);
268
+ const mode = this.#mode(id);
269
+ if (node === undefined || mode === undefined) return undefined;
270
+ const definition = Object.hasOwn(this.#workflow.schema.nodes, node.classType)
271
+ ? this.#workflow.schema.nodes[node.classType]
272
+ : undefined;
273
+ // Whatever is written as raw(…), known class or not, has `Out<"*">` outputs.
274
+ if (mode === "raw" || definition === undefined) return STAR;
275
+ const output = definition.outputs[slot];
276
+ if (output === undefined) return undefined;
277
+ return outputAtom(output, (template) => this.#parameter(node, definition, template));
278
+ }
279
+
280
+ /**
281
+ * A typed node's MatchType parameter: what its inferring position is linked to, or the
282
+ * parameter's default (`"*"`, or the allowed types) when that position is empty or `*`.
283
+ */
284
+ #parameter(node: GraphNode, definition: NodeDef, templateId: string): Atom {
285
+ const key = `${node.id}\u0000${templateId}`;
286
+ const cached = this.#parameters.get(key);
287
+ if (cached !== undefined) return cached;
288
+ // Guard against cycles while resolving: they can't be written anyway.
289
+ this.#parameters.set(key, STAR);
290
+ const position = inferringPosition(definition, templateId);
291
+ const fallback = position === undefined ? STAR : allowedDefault(position.allowed);
292
+ const value = position === undefined ? undefined : position.read(node.inputs);
293
+ const linked =
294
+ value !== undefined && isOutputRef(value) ? this.#atom(value.node, value.slot) : undefined;
295
+ const resolved = linked === undefined || linked.kind === "star" ? fallback : linked;
296
+ this.#parameters.set(key, resolved);
297
+ return resolved;
298
+ }
299
+ }
300
+
301
+ /** The node being checked. */
302
+ interface Scope {
303
+ readonly node: GraphNode;
304
+ readonly definition: NodeDef;
305
+ }
306
+
307
+ /**
308
+ * What an output reference is typed as: `Out<"*">` (fits every input), `Out<string>` (fits
309
+ * only `*` inputs), `Out<OptionList>`, or `Out<"A" | "B">`.
310
+ */
311
+ type Atom =
312
+ | { readonly kind: "star" }
313
+ | { readonly kind: "string" }
314
+ | { readonly kind: "optionList" }
315
+ | { readonly kind: "names"; readonly names: readonly string[] };
316
+
317
+ const STAR: Atom = { kind: "star" };
318
+ const STRING: Atom = { kind: "string" };
319
+
320
+ function outputAtom(output: OutputDef, parameter: (template: string) => Atom): Atom {
321
+ if (output.matchTemplate !== undefined) return parameter(output.matchTemplate);
322
+ const { type } = output;
323
+ switch (type.kind) {
324
+ case "combo":
325
+ return { kind: "optionList" };
326
+ case "any":
327
+ return STAR;
328
+ case "named":
329
+ return type.name === "*" ? STRING : { kind: "names", names: [type.name] };
330
+ case "union":
331
+ return { kind: "names", names: type.names.filter((name) => name !== "*") };
332
+ }
333
+ }
334
+
335
+ /** emit-types `#link`: what a socket of `type` takes. */
336
+ function linkAccepts(type: SocketType): (atom: Atom) => boolean {
337
+ switch (type.kind) {
338
+ case "any":
339
+ return () => true;
340
+ case "named":
341
+ return (atom) =>
342
+ atom.kind === "star" ||
343
+ (atom.kind === "names" && atom.names.every((name) => name === type.name)) ||
344
+ (atom.kind === "optionList" && type.name === "COMBO");
345
+ case "union": {
346
+ const names = new Set(type.names.filter((name) => name !== "*"));
347
+ return (atom) =>
348
+ atom.kind === "star" ||
349
+ (atom.kind === "names" && atom.names.every((name) => names.has(name)));
350
+ }
351
+ }
352
+ }
353
+
354
+ /** A MatchType constraint (emit-types `#allowed`): `undefined` for `string`, else its names. */
355
+ function allowedAtom(type: SocketType): readonly string[] | undefined {
356
+ switch (type.kind) {
357
+ case "any":
358
+ return undefined;
359
+ case "named":
360
+ return type.name === "*" ? undefined : [type.name];
361
+ case "union":
362
+ return type.names.includes("*") ? undefined : type.names;
363
+ }
364
+ }
365
+
366
+ /** A MatchType parameter's default: `"*"` when unconstrained, else the allowed types. */
367
+ function allowedDefault(type: SocketType): Atom {
368
+ const names = allowedAtom(type);
369
+ return names === undefined ? STAR : { kind: "names", names };
370
+ }
371
+
372
+ /** Whether a link typed `atom` infers a parameter within `constraint`. */
373
+ function within(atom: Atom, constraint: readonly string[] | undefined): boolean {
374
+ if (constraint === undefined) return true;
375
+ return atom.kind === "names" && atom.names.every((name) => constraint.includes(name));
376
+ }
377
+
378
+ /** `Out<Match<T>>`: anything when `T` is `"*"` or `string`, else `T` or `*`. */
379
+ function matchAccepts(parameter: Atom, atom: Atom): boolean {
380
+ if (parameter.kind === "star" || parameter.kind === "string" || atom.kind === "star") return true;
381
+ if (parameter.kind === "optionList") return atom.kind === "optionList";
382
+ return atom.kind === "names" && atom.names.every((name) => parameter.names.includes(name));
383
+ }
384
+
385
+ function describe(atom: Atom): string {
386
+ switch (atom.kind) {
387
+ case "star":
388
+ return "*";
389
+ case "string":
390
+ return "a padded *";
391
+ case "optionList":
392
+ return "an option list";
393
+ case "names":
394
+ return atom.names.join(" | ") || "never";
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The position a template is inferred at (emit-types `#matchAtom`): its first MatchType position
400
+ * in declaration order, depth-first through every dynamic-combo option, and slot 0 of an
401
+ * Autogrow. `read` finds its value in a node's structured inputs.
402
+ */
403
+ function inferringPosition(
404
+ definition: NodeDef,
405
+ templateId: string,
406
+ ):
407
+ | {
408
+ readonly input: InputDef;
409
+ readonly allowed: SocketType;
410
+ readonly read: (inputs: { readonly [name: string]: InputValue }) => InputValue | undefined;
411
+ }
412
+ | undefined {
413
+ type Reader = (inputs: { readonly [name: string]: InputValue }) => InputValue | undefined;
414
+ type Found = { readonly input: InputDef; readonly allowed: SocketType; readonly read: Reader };
415
+ const visit = (input: InputDef, read: Reader): Found | undefined => {
416
+ if (input.kind === "matchType" && input.templateId === templateId) {
417
+ return { input, allowed: input.allowed, read };
418
+ }
419
+ if (input.kind === "autogrow" && input.template.kind === "matchType") {
420
+ if (input.template.templateId !== templateId) return undefined;
421
+ const first = input.slots[0];
422
+ return {
423
+ input: input.template,
424
+ allowed: input.template.allowed,
425
+ read: (inputs) => {
426
+ const value = read(inputs);
427
+ if (isList(value)) return value[0];
428
+ return isPlainObject(value) && first !== undefined ? value[first] : undefined;
429
+ },
430
+ };
431
+ }
432
+ if (input.kind === "dynamicCombo") {
433
+ for (const option of input.options) {
434
+ for (const child of option.inputs) {
435
+ const found = visit(child, (inputs) => {
436
+ const value = read(inputs);
437
+ if (!isPlainObject(value) || value["option"] !== option.key) return undefined;
438
+ const children = value["inputs"];
439
+ return isPlainObject(children) && Object.hasOwn(children, child.name)
440
+ ? children[child.name]
441
+ : undefined;
442
+ });
443
+ if (found !== undefined) return found;
444
+ }
445
+ }
446
+ }
447
+ return undefined;
448
+ };
449
+ for (const input of definition.inputs) {
450
+ const found = visit(input, (inputs) =>
451
+ Object.hasOwn(inputs, input.name) ? inputs[input.name] : undefined,
452
+ );
453
+ if (found !== undefined) return found;
454
+ }
455
+ return undefined;
456
+ }
package/src/values.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Structured input values (the graph's `InputValue`): lists, plain objects and the output
2
+ // references inside them, as the transpiler and its typing walk them.
3
+
4
+ import { type InputValue, isOutputRef, type JsonValue, type NodeId } from "@tscomfy/core";
5
+
6
+ /** Every output reference in a node's inputs, depth-first. */
7
+ export function references(
8
+ value: InputValue | { readonly [name: string]: InputValue },
9
+ ): { readonly node: NodeId; readonly slot: number }[] {
10
+ if (isOutputRef(value)) return [{ node: value.node, slot: value.slot }];
11
+ if (isList(value)) return value.flatMap(references);
12
+ if (typeof value === "object" && value !== null) return Object.values(value).flatMap(references);
13
+ return [];
14
+ }
15
+
16
+ /** Whether a value is a plain object: not a list and not an output reference. */
17
+ export function isPlainObject(
18
+ value: InputValue | undefined,
19
+ ): value is { readonly [key: string]: InputValue } {
20
+ return typeof value === "object" && value !== null && !isList(value) && !isOutputRef(value);
21
+ }
22
+
23
+ /** Whether a value is a list. */
24
+ export function isList<T extends InputValue | JsonValue>(
25
+ value: T | undefined,
26
+ ): value is Extract<T, readonly unknown[]> {
27
+ return Array.isArray(value);
28
+ }