@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
@@ -0,0 +1,639 @@
1
+ // Workflow → TypeScript (docs/plan.md §10): the builder code that rebuilds a workflow over the
2
+ // generated types. Nodes the types can express become `n.Class(…)` calls; the rest become
3
+ // `raw(…)` with their wire keys, so the program always rebuilds the same prompt.
4
+ //
5
+ // Whether the types can express a node is decided by the same rules `emit-types.ts` writes the
6
+ // types with (`typing.ts`, §6.2, §6.3), so the output compiles: a test type-checks it for every
7
+ // mock and for the corpus. The layout is oxfmt's (`expressions.ts`).
8
+
9
+ import {
10
+ createDiagnostic,
11
+ type Diagnostic,
12
+ exportApi,
13
+ type GraphNode,
14
+ type InputDef,
15
+ type InputValue,
16
+ isOutputRef,
17
+ type JsonValue,
18
+ type NodeDef,
19
+ type NodeId,
20
+ stringify,
21
+ type Workflow,
22
+ } from "@tscomfy/core";
23
+ import { type Doc, hardline, indent, join, print, PRINT_WIDTH, textWidth } from "./doc.ts";
24
+ import { exportNameProblem, isExportName, TRANSPILED_MODULE_NAMES } from "./export-name.ts";
25
+ import {
26
+ arrayDoc,
27
+ breaksArray,
28
+ callDoc,
29
+ declarationDoc,
30
+ declarationLayout,
31
+ declarationLeft,
32
+ isNumberList,
33
+ jsonDoc,
34
+ type Layout,
35
+ numbersDoc,
36
+ objectDoc,
37
+ objectKey,
38
+ propertyDoc,
39
+ scalarDoc,
40
+ STATEMENT_INDENT,
41
+ statementDoc,
42
+ } from "./expressions.ts";
43
+ import { CODEGEN_VERSION } from "./generate.ts";
44
+ import { headerLines, NameRegistry, quote } from "./names.ts";
45
+ import { hasDefault, isRequired } from "./rules.ts";
46
+ import { Typing, type WriteMode } from "./typing.ts";
47
+ import { isList, isPlainObject, references } from "./values.ts";
48
+
49
+ /** Options for {@link transpile}. */
50
+ export interface TranspileOptions {
51
+ /** The exported constant holding the workflow, e.g. `txt2img`. */
52
+ readonly name: string;
53
+ /** Where the generated schema is imported from, e.g. `./comfy/local/index.js`. */
54
+ readonly schemaModule: string;
55
+ /** The schema module's export, e.g. `local`. */
56
+ readonly schemaName: string;
57
+ /** Inline a node used once into its use, unless that node inlines another. Default `true`. */
58
+ readonly inline?: boolean;
59
+ /** Extra header lines, e.g. the command that transpiled the file. */
60
+ readonly header?: readonly string[];
61
+ }
62
+
63
+ /** What {@link transpile} returns. */
64
+ export interface TranspileResult {
65
+ /** The module source, formatted as oxfmt formats it. */
66
+ readonly code: string;
67
+ readonly diagnostics: readonly Diagnostic[];
68
+ }
69
+
70
+ /**
71
+ * Builder code for `workflow`: an ES module exporting the workflow, built with
72
+ * the generated types over `options.schemaName`. Nodes keep their ids and titles and come in
73
+ * dependency order; inputs equal to their default are left out, since the exporter fills them
74
+ * in. Running it and exporting gives the same prompt as `toApi(workflow)`, except that ids that
75
+ * aren't integers (`40:31`) come in dependency order, where the workflow lists one before a node
76
+ * it depends on. Pure and deterministic.
77
+ *
78
+ * Reports `W_TRANSPILED_AS_RAW` for a node the types can't express, which is written as
79
+ * `raw(…)`, and `E_DEPENDENCY_CYCLE` and `E_DANGLING_LINK` for what no program can build: those
80
+ * nodes, or links, are left out.
81
+ */
82
+ export function transpile(workflow: Workflow, options: TranspileOptions): TranspileResult {
83
+ const problem = transpileNameProblem(options);
84
+ if (problem !== undefined) throw new TypeError(`transpile: ${problem}`);
85
+ return new Transpiler(workflow, options).run();
86
+ }
87
+
88
+ /**
89
+ * Why {@link transpile} refuses these names, or `undefined` when it takes them: each must pass
90
+ * `exportNameProblem`, they must differ, and the declaration
91
+ * `export const <name>: Workflow<NodesOf<typeof <schemaName>>> =` must fit on one line.
92
+ */
93
+ export function transpileNameProblem(
94
+ options: Pick<TranspileOptions, "name" | "schemaName">,
95
+ ): string | undefined {
96
+ for (const [label, value] of [
97
+ ["name", options.name],
98
+ ["schema name", options.schemaName],
99
+ ] as const) {
100
+ const problem = exportNameProblem(value);
101
+ if (problem !== undefined) return `the ${label} ${JSON.stringify(value)} won't do: ${problem}`;
102
+ }
103
+ if (options.name === options.schemaName) {
104
+ return `the name and the schema name are both ${JSON.stringify(options.name)}`;
105
+ }
106
+ const left = declarationLeft(options);
107
+ // Export names are ASCII, so their length is their width.
108
+ if (textWidth(left) > PRINT_WIDTH) {
109
+ return `the declaration \`${left}\` is wider than ${String(PRINT_WIDTH)} columns`;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /** How one node is written. */
115
+ interface Plan {
116
+ readonly node: GraphNode;
117
+ readonly definition: NodeDef | undefined;
118
+ readonly mode: WriteMode;
119
+ /** The node's exported inputs, which a raw node is written with; links are `[id, slot]`. */
120
+ readonly wire: { readonly [key: string]: JsonValue };
121
+ }
122
+
123
+ class Transpiler {
124
+ readonly #workflow: Workflow;
125
+ readonly #options: TranspileOptions;
126
+ readonly #diagnostics: Diagnostic[] = [];
127
+ readonly #plans = new Map<NodeId, Plan>();
128
+ readonly #references = new Map<string, Doc>();
129
+ readonly #inlined = new Set<NodeId>();
130
+ readonly #usedSlots = new Map<NodeId, Set<number>>();
131
+ readonly #names: NameRegistry;
132
+ #usesN = false;
133
+ #usesRaw = false;
134
+ #layout: Layout = "hug";
135
+
136
+ constructor(workflow: Workflow, options: TranspileOptions) {
137
+ this.#workflow = workflow;
138
+ this.#options = options;
139
+ this.#names = new NameRegistry([...TRANSPILED_MODULE_NAMES, options.name, options.schemaName]);
140
+ }
141
+
142
+ run(): TranspileResult {
143
+ const nodes = [...this.#workflow.graph.nodes.values()];
144
+ // Before modes: a bypassed or muted raw node is written with its own inputs.
145
+ const exported = exportApi(this.#workflow, { validate: false, meta: false, modes: false });
146
+ // What the exporter can't send, the program can't build either.
147
+ this.#diagnostics.push(...exported.diagnostics);
148
+ const order = this.#order(nodes);
149
+ // A link to an output a node doesn't have can only be written as raw(…).output(slot).
150
+ const missing = new Map<NodeId, number>();
151
+ for (const node of nodes) {
152
+ for (const ref of references(node.inputs)) {
153
+ const source = this.#workflow.graph.nodes.get(ref.node);
154
+ const outputs =
155
+ source === undefined ? undefined : this.#workflow.schema.nodes[source.classType]?.outputs;
156
+ if (outputs !== undefined && outputs[ref.slot] === undefined) {
157
+ missing.set(ref.node, ref.slot);
158
+ }
159
+ }
160
+ }
161
+ // In dependency order, so a node's sources are decided first: a raw source is `Out<"*">`.
162
+ const typing = new Typing(this.#workflow, (id) => this.#plans.get(id)?.mode);
163
+ for (const node of order) {
164
+ const definition = Object.hasOwn(this.#workflow.schema.nodes, node.classType)
165
+ ? this.#workflow.schema.nodes[node.classType]
166
+ : undefined;
167
+ const slot = missing.get(node.id);
168
+ const reason =
169
+ node.raw || definition === undefined
170
+ ? undefined
171
+ : slot === undefined
172
+ ? typing.problem(node, definition)
173
+ : `output ${String(slot)} is linked, but it has no such output`;
174
+ if (reason !== undefined) {
175
+ this.#diagnostics.push(
176
+ createDiagnostic(
177
+ "W_TRANSPILED_AS_RAW",
178
+ [node.id],
179
+ `Node ${node.id} (${node.classType}) is written as raw(…): ${reason}.`,
180
+ ),
181
+ );
182
+ }
183
+ this.#plans.set(node.id, {
184
+ node,
185
+ definition,
186
+ mode: node.raw || definition === undefined || reason !== undefined ? "raw" : "typed",
187
+ wire: exported.prompt[node.id]?.inputs ?? {},
188
+ });
189
+ }
190
+ const plans = order.flatMap((node) => this.#plans.get(node.id) ?? []);
191
+ this.#usesN = plans.some((plan) => plan.mode === "typed");
192
+ this.#usesRaw = plans.some((plan) => plan.mode === "raw");
193
+ // Known before any statement: how deep the statements sit decides their layout.
194
+ this.#layout = declarationLayout(this.#options, this.#build(), plans.length === 0);
195
+ const uses = this.#uses(plans);
196
+ const statements: Doc[] = [];
197
+ for (const plan of plans) {
198
+ const statement = this.#statement(plan, uses);
199
+ if (statement !== undefined) statements.push(statement);
200
+ }
201
+ return { code: this.#module(statements), diagnostics: this.#diagnostics };
202
+ }
203
+
204
+ /**
205
+ * Dependency order, ties broken by graph order. No program can build a cycle, or a node that
206
+ * depends on one: they are reported and left out.
207
+ */
208
+ #order(nodes: readonly GraphNode[]): GraphNode[] {
209
+ const index = new Map(nodes.map((node, position) => [node.id, position]));
210
+ const dependents = new Map<NodeId, NodeId[]>();
211
+ const waiting = new Map<NodeId, number>();
212
+ for (const node of nodes) {
213
+ const sources = new Set(
214
+ references(node.inputs).flatMap((ref) => (index.has(ref.node) ? [ref.node] : [])),
215
+ );
216
+ waiting.set(node.id, sources.size);
217
+ for (const source of sources) {
218
+ const list = dependents.get(source);
219
+ if (list === undefined) dependents.set(source, [node.id]);
220
+ else list.push(node.id);
221
+ }
222
+ }
223
+ const ready = new MinHeap();
224
+ for (const node of nodes) if (waiting.get(node.id) === 0) ready.push(index.get(node.id) ?? 0);
225
+ const order: GraphNode[] = [];
226
+ for (let at = ready.pop(); at !== undefined; at = ready.pop()) {
227
+ const node = nodes[at];
228
+ if (node === undefined) continue;
229
+ order.push(node);
230
+ for (const dependent of dependents.get(node.id) ?? []) {
231
+ const left = (waiting.get(dependent) ?? 0) - 1;
232
+ waiting.set(dependent, left);
233
+ if (left === 0) ready.push(index.get(dependent) ?? 0);
234
+ }
235
+ }
236
+ const left = nodes.filter((node) => (waiting.get(node.id) ?? 0) > 0);
237
+ const cyclic = inCycles(left);
238
+ for (const node of left) {
239
+ this.#diagnostics.push(
240
+ createDiagnostic(
241
+ "E_DEPENDENCY_CYCLE",
242
+ [node.id],
243
+ cyclic.has(node.id)
244
+ ? `Node ${node.id} (${node.classType}) depends on itself through its links; leaving it out.`
245
+ : `Node ${node.id} (${node.classType}) depends on nodes in a cycle; leaving it out.`,
246
+ ),
247
+ );
248
+ }
249
+ return order;
250
+ }
251
+
252
+ /** How often each output (`id`, `slot`) is used by what will be printed. */
253
+ #uses(plans: readonly Plan[]): Map<string, number> {
254
+ const uses = new Map<string, number>();
255
+ for (const plan of plans) {
256
+ // A raw node prints its exported inputs, which may drop what the exporter can't send.
257
+ const refs =
258
+ plan.mode === "raw"
259
+ ? Object.values(plan.wire).flatMap((value) =>
260
+ isLink(value) ? [{ node: value[0], slot: value[1] }] : [],
261
+ )
262
+ : references(plan.node.inputs);
263
+ for (const ref of refs) {
264
+ const key = slotKey(ref.node, ref.slot);
265
+ uses.set(key, (uses.get(key) ?? 0) + 1);
266
+ const slots = this.#usedSlots.get(ref.node);
267
+ if (slots === undefined) this.#usedSlots.set(ref.node, new Set([ref.slot]));
268
+ else slots.add(ref.slot);
269
+ }
270
+ }
271
+ return uses;
272
+ }
273
+
274
+ /** The statement for one node, or `undefined` when it is inlined into its only use. */
275
+ #statement(plan: Plan, uses: ReadonlyMap<string, number>): Doc | undefined {
276
+ const { node, definition } = plan;
277
+ const options = this.#nodeOptions(node);
278
+ if (plan.mode === "raw" || definition === undefined) {
279
+ const call = callDoc("raw", [quote(node.classType), this.#wireObject(plan), options]);
280
+ const slots = this.#usedSlots.get(node.id);
281
+ if (slots === undefined) return [call, ";"];
282
+ const name = this.#claim(camel(node.classType, "node"));
283
+ for (const slot of slots) {
284
+ this.#references.set(slotKey(node.id, slot), [name, ".output(", String(slot), ")"]);
285
+ }
286
+ return statementDoc(`const ${name}`, call);
287
+ }
288
+ const callee = /^[A-Za-z_$][\w$]*$/.test(node.classType)
289
+ ? `n.${node.classType}`
290
+ : `n[${quote(node.classType)}]`;
291
+ const call = callDoc(callee, [this.#inputsObject(definition.inputs, node.inputs).doc, options]);
292
+ const { outputs } = definition;
293
+ const used = outputs.filter((output) => (uses.get(slotKey(node.id, output.index)) ?? 0) > 0);
294
+ if (used.length === 0) return [call, ";"];
295
+ if (outputs.length === 1) {
296
+ const key = slotKey(node.id, 0);
297
+ // Used once, by a node written later, and inlining nothing itself: written in place. Not
298
+ // with an id that isn't an array index: such keys keep the order nodes are created in.
299
+ if (
300
+ this.#options.inline !== false &&
301
+ isArrayIndex(node.id) &&
302
+ uses.get(key) === 1 &&
303
+ references(node.inputs).every((ref) => !this.#inlined.has(ref.node))
304
+ ) {
305
+ this.#inlined.add(node.id);
306
+ this.#references.set(key, call);
307
+ return undefined;
308
+ }
309
+ const name = this.#claim(camel(outputs[0]?.name ?? node.classType, "output"));
310
+ this.#references.set(key, name);
311
+ return statementDoc(`const ${name}`, call);
312
+ }
313
+ // Several outputs: destructure the ones used, by their names, while the pattern fits on its
314
+ // line (Prettier would break a longer one); otherwise index one variable.
315
+ const last = Math.max(...used.map((output) => output.index));
316
+ const bindings = outputs
317
+ .slice(0, last + 1)
318
+ .map((output) => (used.includes(output) ? this.#claim(camel(output.name, "output")) : ""));
319
+ const pattern = `const [${bindings.join(", ")}] =`;
320
+ if (STATEMENT_INDENT[this.#layout] + textWidth(pattern) <= PRINT_WIDTH) {
321
+ for (const [index, binding] of bindings.entries()) {
322
+ if (binding !== "") this.#references.set(slotKey(node.id, index), binding);
323
+ }
324
+ return statementDoc(`const [${bindings.join(", ")}]`, call);
325
+ }
326
+ const name = this.#claim(camel(node.classType, "node"));
327
+ for (const output of used) {
328
+ this.#references.set(slotKey(node.id, output.index), [name, "[", String(output.index), "]"]);
329
+ }
330
+ return statementDoc(`const ${name}`, call);
331
+ }
332
+
333
+ #claim(base: string): string {
334
+ // A word no export may be gets a suffix, except the module's own names, which the registry
335
+ // holds: those get a number, like any other taken name.
336
+ const usable = isExportName(base) || TRANSPILED_MODULE_NAMES.includes(base);
337
+ return this.#names.claim(usable ? base : `${base}Node`);
338
+ }
339
+
340
+ /** `{ id: "5" }`, and the title and mode if there are. */
341
+ #nodeOptions(node: GraphNode): Doc {
342
+ return objectDoc([
343
+ propertyDoc("id", quote(node.id)),
344
+ ...(node.title === undefined ? [] : [propertyDoc("title", quote(node.title))]),
345
+ ...(node.mode === undefined ? [] : [propertyDoc("mode", quote(node.mode))]),
346
+ ]);
347
+ }
348
+
349
+ /**
350
+ * Inputs as an object literal, in the order given (the exporter orders them by the schema).
351
+ * Values equal to their default are left out at every level, as the exporter fills them in.
352
+ * Also returns how many properties were written.
353
+ */
354
+ #inputsObject(
355
+ definitions: readonly InputDef[],
356
+ inputs: { readonly [name: string]: InputValue },
357
+ ): { readonly doc: Doc; readonly count: number } {
358
+ const known = new Map(definitions.map((input) => [input.name, input]));
359
+ const properties: Doc[] = [];
360
+ for (const [name, value] of Object.entries(inputs)) {
361
+ const definition = known.get(name);
362
+ // The exporter fills defaults in; but what the types require must be written.
363
+ if (definition !== undefined && !isRequired(definition) && equalsDefault(definition, value)) {
364
+ continue;
365
+ }
366
+ const key = objectKey(name);
367
+ const doc = this.#value(value, definition);
368
+ // `{ model }` rather than `{ model: model }`.
369
+ properties.push(doc === key ? key : propertyDoc(key, doc));
370
+ }
371
+ return { doc: objectDoc(properties), count: properties.length };
372
+ }
373
+
374
+ /** A structured value as an expression. */
375
+ #value(value: InputValue, definition: InputDef | undefined): Doc {
376
+ if (isOutputRef(value)) return this.#reference(value.node, value.slot);
377
+ if (isList(value)) {
378
+ if (definition?.kind === "dynamicGroup") {
379
+ const rows = value.map((row) =>
380
+ isPlainObject(row)
381
+ ? this.#inputsObject(definition.fields, row)
382
+ : { doc: this.#value(row, undefined), count: 0 },
383
+ );
384
+ // Rows as printed, defaults left out, decide whether the list must break.
385
+ const breaks = rows.length > 1 && rows.every((row) => row.count > 1);
386
+ return arrayDoc(
387
+ rows.map((row) => row.doc),
388
+ breaks,
389
+ );
390
+ }
391
+ const element = definition?.kind === "autogrow" ? definition.template : undefined;
392
+ if (isNumberList(value)) return numbersDoc(value);
393
+ return arrayDoc(
394
+ value.map((item: InputValue) => this.#value(item, element)),
395
+ breaksArray(value),
396
+ );
397
+ }
398
+ if (isPlainObject(value)) {
399
+ if (definition?.kind === "dynamicCombo" && typeof value["option"] === "string") {
400
+ const key = value["option"];
401
+ const chosen = definition.options.find((option) => option.key === key);
402
+ const children = value["inputs"];
403
+ const inputs = isPlainObject(children)
404
+ ? this.#inputsObject(chosen?.inputs ?? [], children)
405
+ : undefined;
406
+ // With every option input at its default, the key alone says the same.
407
+ if (inputs === undefined || inputs.count === 0) return quote(key);
408
+ return objectDoc([propertyDoc("option", quote(key)), propertyDoc("inputs", inputs.doc)]);
409
+ }
410
+ const element = definition?.kind === "autogrow" ? definition.template : undefined;
411
+ return objectDoc(
412
+ Object.entries(value).map(([key, item]) =>
413
+ propertyDoc(objectKey(key), this.#value(item, element)),
414
+ ),
415
+ );
416
+ }
417
+ return scalarDoc(value);
418
+ }
419
+
420
+ /** A raw node's inputs: wire keys, links as references, lists unwrapped (raw wraps them). */
421
+ #wireObject(plan: Plan): Doc {
422
+ return objectDoc(
423
+ Object.entries(plan.wire).flatMap(([key, value]): Doc[] => {
424
+ if (isLink(value)) {
425
+ const [node, slot] = value;
426
+ if (!this.#workflow.graph.nodes.has(node)) {
427
+ this.#dangling(plan.node.id, key, node);
428
+ return [];
429
+ }
430
+ return [propertyDoc(objectKey(key), this.#reference(node, slot))];
431
+ }
432
+ const unwrapped = isWrappedList(value) ? value["__value__"] : value;
433
+ return [propertyDoc(objectKey(key), jsonDoc(unwrapped ?? null))];
434
+ }),
435
+ );
436
+ }
437
+
438
+ #reference(node: NodeId, slot: number): Doc {
439
+ const reference = this.#references.get(slotKey(node, slot));
440
+ // Sources are written before their uses, and left-out nodes have no uses written.
441
+ if (reference === undefined)
442
+ throw new Error(`transpile: no reference for ${node}:${String(slot)}`);
443
+ return reference;
444
+ }
445
+
446
+ #dangling(id: NodeId, key: string, node: NodeId): void {
447
+ this.#diagnostics.push(
448
+ createDiagnostic(
449
+ "E_DANGLING_LINK",
450
+ [id, "inputs", key],
451
+ `Input ${key} of node ${id} links to node ${node}, which the workflow doesn't have; leaving it out.`,
452
+ ),
453
+ );
454
+ }
455
+
456
+ /** The build function's head: `({ n, raw }) =>` with what the statements use. */
457
+ #build(): string {
458
+ const context = [...(this.#usesN ? ["n"] : []), ...(this.#usesRaw ? ["raw"] : [])];
459
+ return `(${context.length === 0 ? "" : `{ ${context.join(", ")} }`}) =>`;
460
+ }
461
+
462
+ #module(statements: readonly Doc[]): string {
463
+ const header = [
464
+ `Transpiled by @tscomfy/codegen ${CODEGEN_VERSION}.`,
465
+ ...(this.#options.header ?? []),
466
+ ];
467
+ const { schemaName } = this.#options;
468
+ const block: Doc =
469
+ statements.length === 0
470
+ ? "{}"
471
+ : ["{", indent([hardline, join(hardline, statements)]), hardline, "}"];
472
+ return [
473
+ ...headerLines(header),
474
+ "",
475
+ 'import { type NodesOf, type Workflow, workflow } from "@tscomfy/core";',
476
+ `import { ${schemaName} } from ${quote(this.#options.schemaModule)};`,
477
+ "",
478
+ print(declarationDoc(this.#options, this.#build(), block, this.#layout), PRINT_WIDTH),
479
+ "",
480
+ ].join("\n");
481
+ }
482
+ }
483
+
484
+ /** The nodes of `nodes` that lie on a cycle among them (Tarjan's algorithm, iteratively). */
485
+ function inCycles(nodes: readonly GraphNode[]): Set<NodeId> {
486
+ const ids = new Set(nodes.map((node) => node.id));
487
+ const edges = new Map(
488
+ nodes.map((node) => [
489
+ node.id,
490
+ [...new Set(references(node.inputs).map((ref) => ref.node))].filter((id) => ids.has(id)),
491
+ ]),
492
+ );
493
+ const order = new Map<NodeId, number>();
494
+ const low = new Map<NodeId, number>();
495
+ const stack: NodeId[] = [];
496
+ const onStack = new Set<NodeId>();
497
+ const cyclic = new Set<NodeId>();
498
+ let counter = 0;
499
+ for (const start of ids) {
500
+ if (order.has(start)) continue;
501
+ const frames: { readonly id: NodeId; next: number }[] = [{ id: start, next: 0 }];
502
+ order.set(start, counter);
503
+ low.set(start, counter++);
504
+ stack.push(start);
505
+ onStack.add(start);
506
+ while (frames.length > 0) {
507
+ const frame = frames.at(-1);
508
+ if (frame === undefined) break;
509
+ const targets = edges.get(frame.id) ?? [];
510
+ const target = targets[frame.next++];
511
+ if (target !== undefined) {
512
+ if (!order.has(target)) {
513
+ order.set(target, counter);
514
+ low.set(target, counter++);
515
+ stack.push(target);
516
+ onStack.add(target);
517
+ frames.push({ id: target, next: 0 });
518
+ } else if (onStack.has(target)) {
519
+ low.set(frame.id, Math.min(low.get(frame.id) ?? 0, order.get(target) ?? 0));
520
+ }
521
+ continue;
522
+ }
523
+ frames.pop();
524
+ const parent = frames.at(-1);
525
+ if (parent !== undefined) {
526
+ low.set(parent.id, Math.min(low.get(parent.id) ?? 0, low.get(frame.id) ?? 0));
527
+ }
528
+ if (low.get(frame.id) !== order.get(frame.id)) continue;
529
+ const component: NodeId[] = [];
530
+ for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
531
+ onStack.delete(id);
532
+ component.push(id);
533
+ if (id === frame.id) break;
534
+ }
535
+ const self = (edges.get(frame.id) ?? []).includes(frame.id);
536
+ if (component.length > 1 || self) for (const id of component) cyclic.add(id);
537
+ }
538
+ }
539
+ return cyclic;
540
+ }
541
+
542
+ /** Whether a key is an array index, which objects order numerically rather than by creation. */
543
+ function isArrayIndex(key: string): boolean {
544
+ return /^(?:0|[1-9][0-9]{0,9})$/.test(key) && Number(key) < 2 ** 32 - 1;
545
+ }
546
+
547
+ /** A binary min-heap of graph positions, for the dependency order's ready list. */
548
+ class MinHeap {
549
+ readonly #items: number[] = [];
550
+
551
+ push(value: number): void {
552
+ const items = this.#items;
553
+ items.push(value);
554
+ for (let at = items.length - 1; at > 0;) {
555
+ const parent = (at - 1) >> 1;
556
+ const [up, down] = [items[at] ?? 0, items[parent] ?? 0];
557
+ if (down <= up) break;
558
+ items[at] = down;
559
+ items[parent] = up;
560
+ at = parent;
561
+ }
562
+ }
563
+
564
+ pop(): number | undefined {
565
+ const items = this.#items;
566
+ const top = items[0];
567
+ const last = items.pop();
568
+ if (top === undefined || last === undefined || items.length === 0) return top;
569
+ items[0] = last;
570
+ for (let at = 0; ;) {
571
+ const [left, right] = [2 * at + 1, 2 * at + 2];
572
+ let smallest = at;
573
+ if (left < items.length && (items[left] ?? 0) < (items[smallest] ?? 0)) smallest = left;
574
+ if (right < items.length && (items[right] ?? 0) < (items[smallest] ?? 0)) smallest = right;
575
+ if (smallest === at) break;
576
+ const [a, b] = [items[at] ?? 0, items[smallest] ?? 0];
577
+ items[at] = b;
578
+ items[smallest] = a;
579
+ at = smallest;
580
+ }
581
+ return top;
582
+ }
583
+ }
584
+
585
+ /** Whether a value equals what the exporter would send for the input when left out. */
586
+ function equalsDefault(input: InputDef, value: InputValue): boolean {
587
+ if (!hasDefault(input) || isOutputRef(value)) return false;
588
+ const fallback = "default" in input ? input.default : undefined;
589
+ return fallback !== undefined && stringify(fallback) === stringify(toJson(value));
590
+ }
591
+
592
+ /** A structured value without references, as JSON; references compare as `null`. */
593
+ function toJson(value: InputValue): JsonValue {
594
+ if (isOutputRef(value)) return null;
595
+ if (isList(value)) return value.map(toJson);
596
+ if (isPlainObject(value)) {
597
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toJson(item)]));
598
+ }
599
+ return value;
600
+ }
601
+
602
+ function slotKey(node: NodeId, slot: number): string {
603
+ return `${node}\u0000${String(slot)}`;
604
+ }
605
+
606
+ function isLink(value: JsonValue): value is readonly [string, number] {
607
+ return (
608
+ isList(value) &&
609
+ value.length === 2 &&
610
+ typeof value[0] === "string" &&
611
+ typeof value[1] === "number"
612
+ );
613
+ }
614
+
615
+ function isWrappedList(value: JsonValue): value is { readonly __value__: readonly JsonValue[] } {
616
+ return (
617
+ typeof value === "object" &&
618
+ value !== null &&
619
+ !isList(value) &&
620
+ Object.keys(value).length === 1 &&
621
+ isList(value["__value__"])
622
+ );
623
+ }
624
+
625
+ /**
626
+ * A camelCase variable name from a ComfyUI name, split at case changes: `LATENT` → `latent`,
627
+ * `VAEDecode` → `vaeDecode`, `CLIP_VISION` → `clipVision`. Never empty, never a leading digit.
628
+ */
629
+ function camel(name: string, fallback: string): string {
630
+ const words = name.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g) ?? [];
631
+ const joined = words
632
+ .map((word, index) => {
633
+ const lower = word.toLowerCase();
634
+ return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
635
+ })
636
+ .join("");
637
+ if (joined === "") return fallback;
638
+ return /^[0-9]/.test(joined) ? `${fallback}${joined}` : joined;
639
+ }