@nodetool-ai/node-sdk 0.7.0-rc.23 → 0.8.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.
Files changed (157) hide show
  1. package/README.md +34 -0
  2. package/dist/.nodetool-build-stamp +1 -0
  3. package/dist/base-node.d.ts +253 -13
  4. package/dist/base-node.d.ts.map +1 -1
  5. package/dist/base-node.js +337 -49
  6. package/dist/base-node.js.map +1 -1
  7. package/dist/class-name-to-title.d.ts +26 -0
  8. package/dist/class-name-to-title.d.ts.map +1 -0
  9. package/dist/class-name-to-title.js +59 -0
  10. package/dist/class-name-to-title.js.map +1 -0
  11. package/dist/code-analysis.d.ts +222 -0
  12. package/dist/code-analysis.d.ts.map +1 -0
  13. package/dist/code-analysis.js +803 -0
  14. package/dist/code-analysis.js.map +1 -0
  15. package/dist/code-body.d.ts +61 -0
  16. package/dist/code-body.d.ts.map +1 -0
  17. package/dist/code-body.js +288 -0
  18. package/dist/code-body.js.map +1 -0
  19. package/dist/code-node-validation.d.ts +67 -0
  20. package/dist/code-node-validation.d.ts.map +1 -0
  21. package/dist/code-node-validation.js +540 -0
  22. package/dist/code-node-validation.js.map +1 -0
  23. package/dist/content-card.d.ts +30 -0
  24. package/dist/content-card.d.ts.map +1 -0
  25. package/dist/content-card.js +68 -0
  26. package/dist/content-card.js.map +1 -0
  27. package/dist/correlation-validation.d.ts +19 -0
  28. package/dist/correlation-validation.d.ts.map +1 -0
  29. package/dist/correlation-validation.js +101 -0
  30. package/dist/correlation-validation.js.map +1 -0
  31. package/dist/cost-estimate.d.ts +244 -0
  32. package/dist/cost-estimate.d.ts.map +1 -0
  33. package/dist/cost-estimate.js +636 -0
  34. package/dist/cost-estimate.js.map +1 -0
  35. package/dist/decorators.d.ts.map +1 -1
  36. package/dist/decorators.js +4 -1
  37. package/dist/decorators.js.map +1 -1
  38. package/dist/docs/nodes.d.ts.map +1 -1
  39. package/dist/docs/nodes.js +3 -2
  40. package/dist/docs/nodes.js.map +1 -1
  41. package/dist/docs/overview.d.ts.map +1 -1
  42. package/dist/docs/overview.js +4 -3
  43. package/dist/docs/overview.js.map +1 -1
  44. package/dist/docs/workflows.d.ts.map +1 -1
  45. package/dist/docs/workflows.js +10 -9
  46. package/dist/docs/workflows.js.map +1 -1
  47. package/dist/field-classification.d.ts +31 -0
  48. package/dist/field-classification.d.ts.map +1 -0
  49. package/dist/field-classification.js +58 -0
  50. package/dist/field-classification.js.map +1 -0
  51. package/dist/graph-validation.d.ts +239 -0
  52. package/dist/graph-validation.d.ts.map +1 -0
  53. package/dist/graph-validation.js +1165 -0
  54. package/dist/graph-validation.js.map +1 -0
  55. package/dist/index.d.ts +27 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +29 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/js-script-link.d.ts +53 -0
  60. package/dist/js-script-link.d.ts.map +1 -0
  61. package/dist/js-script-link.js +58 -0
  62. package/dist/js-script-link.js.map +1 -0
  63. package/dist/js-script-materialize.d.ts +52 -0
  64. package/dist/js-script-materialize.d.ts.map +1 -0
  65. package/dist/js-script-materialize.js +24 -0
  66. package/dist/js-script-materialize.js.map +1 -0
  67. package/dist/manifest-node-values.d.ts +106 -0
  68. package/dist/manifest-node-values.d.ts.map +1 -0
  69. package/dist/manifest-node-values.js +195 -0
  70. package/dist/manifest-node-values.js.map +1 -0
  71. package/dist/metadata.d.ts +73 -3
  72. package/dist/metadata.d.ts.map +1 -1
  73. package/dist/metadata.js +132 -49
  74. package/dist/metadata.js.map +1 -1
  75. package/dist/node-metadata.d.ts.map +1 -1
  76. package/dist/node-metadata.js +58 -56
  77. package/dist/node-metadata.js.map +1 -1
  78. package/dist/node-type-inventory.d.ts +51 -0
  79. package/dist/node-type-inventory.d.ts.map +1 -0
  80. package/dist/node-type-inventory.js +121 -0
  81. package/dist/node-type-inventory.js.map +1 -0
  82. package/dist/nodes/test-nodes.d.ts +77 -30
  83. package/dist/nodes/test-nodes.d.ts.map +1 -1
  84. package/dist/nodes/test-nodes.js +2 -34
  85. package/dist/nodes/test-nodes.js.map +1 -1
  86. package/dist/pack-loader.d.ts +202 -0
  87. package/dist/pack-loader.d.ts.map +1 -0
  88. package/dist/pack-loader.js +529 -0
  89. package/dist/pack-loader.js.map +1 -0
  90. package/dist/package-registry-client.d.ts.map +1 -1
  91. package/dist/package-registry-client.js +15 -6
  92. package/dist/package-registry-client.js.map +1 -1
  93. package/dist/port-types.d.ts +15 -0
  94. package/dist/port-types.d.ts.map +1 -0
  95. package/dist/port-types.js +72 -0
  96. package/dist/port-types.js.map +1 -0
  97. package/dist/pricing-bundle.d.ts +83 -0
  98. package/dist/pricing-bundle.d.ts.map +1 -0
  99. package/dist/pricing-bundle.js +82 -0
  100. package/dist/pricing-bundle.js.map +1 -0
  101. package/dist/pricing-params.d.ts +59 -0
  102. package/dist/pricing-params.d.ts.map +1 -0
  103. package/dist/pricing-params.js +282 -0
  104. package/dist/pricing-params.js.map +1 -0
  105. package/dist/python-package-scan.d.ts +8 -0
  106. package/dist/python-package-scan.d.ts.map +1 -1
  107. package/dist/python-package-scan.js +21 -8
  108. package/dist/python-package-scan.js.map +1 -1
  109. package/dist/registry.d.ts +105 -2
  110. package/dist/registry.d.ts.map +1 -1
  111. package/dist/registry.js +393 -30
  112. package/dist/registry.js.map +1 -1
  113. package/dist/sandbox-bridge-packs.d.ts +39 -0
  114. package/dist/sandbox-bridge-packs.d.ts.map +1 -0
  115. package/dist/sandbox-bridge-packs.js +249 -0
  116. package/dist/sandbox-bridge-packs.js.map +1 -0
  117. package/dist/sandbox-catalog-host.d.ts +31 -0
  118. package/dist/sandbox-catalog-host.d.ts.map +1 -0
  119. package/dist/sandbox-catalog-host.js +78 -0
  120. package/dist/sandbox-catalog-host.js.map +1 -0
  121. package/dist/sandbox-module-catalog.d.ts +14 -0
  122. package/dist/sandbox-module-catalog.d.ts.map +1 -0
  123. package/dist/sandbox-module-catalog.js +402 -0
  124. package/dist/sandbox-module-catalog.js.map +1 -0
  125. package/dist/sandbox-npm-artifacts.d.ts +56 -0
  126. package/dist/sandbox-npm-artifacts.d.ts.map +1 -0
  127. package/dist/sandbox-npm-artifacts.js +17 -0
  128. package/dist/sandbox-npm-artifacts.js.map +1 -0
  129. package/dist/sandbox-pack-discovery.d.ts +93 -0
  130. package/dist/sandbox-pack-discovery.d.ts.map +1 -0
  131. package/dist/sandbox-pack-discovery.js +807 -0
  132. package/dist/sandbox-pack-discovery.js.map +1 -0
  133. package/dist/search.d.ts +29 -1
  134. package/dist/search.d.ts.map +1 -1
  135. package/dist/search.js +136 -9
  136. package/dist/search.js.map +1 -1
  137. package/dist/type-compat.d.ts +36 -0
  138. package/dist/type-compat.d.ts.map +1 -0
  139. package/dist/type-compat.js +98 -0
  140. package/dist/type-compat.js.map +1 -0
  141. package/dist/type-predicates.d.ts +25 -0
  142. package/dist/type-predicates.d.ts.map +1 -0
  143. package/dist/type-predicates.js +31 -0
  144. package/dist/type-predicates.js.map +1 -0
  145. package/dist/validation.d.ts +18 -0
  146. package/dist/validation.d.ts.map +1 -1
  147. package/dist/validation.js +70 -11
  148. package/dist/validation.js.map +1 -1
  149. package/dist/workflow-document-tools.d.ts +34 -0
  150. package/dist/workflow-document-tools.d.ts.map +1 -0
  151. package/dist/workflow-document-tools.js +357 -0
  152. package/dist/workflow-document-tools.js.map +1 -0
  153. package/dist/workflow-interface.d.ts +62 -0
  154. package/dist/workflow-interface.d.ts.map +1 -0
  155. package/dist/workflow-interface.js +351 -0
  156. package/dist/workflow-interface.js.map +1 -0
  157. package/package.json +67 -10
@@ -0,0 +1,1165 @@
1
+ /**
2
+ * Static workflow-graph validation.
3
+ *
4
+ * Checks a graph against the node registry WITHOUT executing it: unknown node
5
+ * types, duplicate ids, missing required / unselected-model properties, dangling
6
+ * edges, unknown edge handles, mis-spelled dynamic slot types, Code node bodies
7
+ * (syntax, inputs, outputs), and (best-effort) edge type mismatches. Catches the
8
+ * breakage that would otherwise only surface after a full workflow run.
9
+ *
10
+ * Takes a {@link GraphValidationRegistry} (the slice of NodeRegistry it needs)
11
+ * so it can be unit-tested with a fake and reused by the CLI and agent tools.
12
+ */
13
+ import { isLoopFeedbackHandle, legacyGameNodeDiagnostic, migrateGraphNodeTypes } from "@nodetool-ai/protocol";
14
+ import { getProcessSandboxModuleCatalog } from "@nodetool-ai/runtime";
15
+ import { inferredCodeInputNames, inferredCodeOutputNames } from "./code-analysis.js";
16
+ import { isJsCodeNodeType, validateCodeNodeBody } from "./code-node-validation.js";
17
+ import { jsScriptPortMismatches, readJsScriptLink } from "./js-script-link.js";
18
+ import { portTypeAliases } from "./port-types.js";
19
+ import { isNonEmptyString, isObjectLike, isRecord, isString } from "./type-predicates.js";
20
+ import { slotTypeToString, typeMetaToString, typesIncompatible, valueIncompatibleWithType } from "./type-compat.js";
21
+ /**
22
+ * Editor-only base nodes that carry no executable class — the graph loader
23
+ * prunes them before a run, so they must not be flagged as "unknown". Their
24
+ * short names; matched against the `nodetool.workflows.base_node.*` namespace.
25
+ */
26
+ const EDITOR_ONLY_NODE_NAMES = new Set([
27
+ "Comment",
28
+ "Group",
29
+ "Reroute"
30
+ ]);
31
+ export function isEditorOnlyType(nodeType) {
32
+ const dot = nodeType.lastIndexOf(".");
33
+ const name = dot >= 0 ? nodeType.slice(dot + 1) : nodeType;
34
+ return (nodeType.includes(".workflows.base_node.") &&
35
+ EDITOR_ONLY_NODE_NAMES.has(name));
36
+ }
37
+ /** Handles like `__control__` / `__output__` are framework-internal, not props. */
38
+ function isReservedHandle(handle) {
39
+ return handle.startsWith("__") && handle.endsWith("__");
40
+ }
41
+ function asRecord(value) {
42
+ if (!isRecord(value)) {
43
+ return undefined;
44
+ }
45
+ return value;
46
+ }
47
+ /**
48
+ * `nodes` / `edges` as the arrays their types promise. The graph is parsed from
49
+ * untrusted JSON on every entry point — a file, a stored row, an LLM's inline
50
+ * graph — so a member that is a number or an object reaches here and has to be
51
+ * reported rather than thrown at `.map`. Absent stays absent: an empty half of
52
+ * a graph is what the other checks already read it as.
53
+ */
54
+ function graphMembers(value, field, issues) {
55
+ if (value == null)
56
+ return [];
57
+ if (Array.isArray(value))
58
+ return value;
59
+ issues.push({
60
+ severity: "error",
61
+ code: "invalid_graph",
62
+ message: `graph.${field} must be an array, not ${isObjectLike(value) ? "an object" : `a ${typeof value}`}`
63
+ });
64
+ return [];
65
+ }
66
+ /**
67
+ * Typed dynamic slot declarations of a node, from either graph shape: the
68
+ * kernel/runner node carries `dynamic_inputs` at the top level, a ReactFlow
69
+ * node carries it inside `data`.
70
+ */
71
+ function readDynamicInputs(node) {
72
+ const own = asRecord(node.dynamic_inputs);
73
+ if (own)
74
+ return own;
75
+ const fromData = asRecord(node.data?.dynamic_inputs);
76
+ return (fromData ?? {});
77
+ }
78
+ /** Inline dynamic slot values, from either graph shape. */
79
+ function readDynamicProperties(node) {
80
+ return (asRecord(node.dynamic_properties) ??
81
+ asRecord(node.data?.dynamic_properties) ??
82
+ {});
83
+ }
84
+ /** Typed dynamic output declarations, from either graph shape. */
85
+ function readDynamicOutputs(node) {
86
+ return (asRecord(node.dynamic_outputs) ?? asRecord(node.data?.dynamic_outputs) ?? {});
87
+ }
88
+ /**
89
+ * The declared properties of a node, from either graph shape.
90
+ *
91
+ * The editor's `NodeData` nests the props bag under `data.properties` (the same
92
+ * wrapper `readDynamicInputs` and friends unwrap). Reading `data` itself as the
93
+ * bag made every editor-exported node look like it was missing every required
94
+ * property. `data` is still the fallback for older/hand-written shapes that
95
+ * flattened the props onto it.
96
+ */
97
+ function readProperties(node) {
98
+ return (asRecord(node.properties) ??
99
+ asRecord(node.data?.properties) ??
100
+ asRecord(node.data) ??
101
+ {});
102
+ }
103
+ /** True for a DSL wiring handle ({__handle: true, source, sourceHandle}). */
104
+ function isWiringHandle(value) {
105
+ return isRecord(value) && value["__handle"] === true;
106
+ }
107
+ /** Property paths holding a wiring handle, e.g. `tiles[0]`. */
108
+ function collectWiringHandlePaths(value, path, out, depth = 0) {
109
+ if (depth > MODEL_REF_MAX_DEPTH)
110
+ return;
111
+ if (Array.isArray(value)) {
112
+ value.forEach((item, index) => collectWiringHandlePaths(item, `${path}[${index}]`, out, depth + 1));
113
+ return;
114
+ }
115
+ if (!isRecord(value))
116
+ return;
117
+ if (isWiringHandle(value)) {
118
+ out.push(path);
119
+ return;
120
+ }
121
+ for (const [key, child] of Object.entries(value)) {
122
+ collectWiringHandlePaths(child, path ? `${path}.${key}` : key, out, depth + 1);
123
+ }
124
+ }
125
+ /**
126
+ * A control edge is spelled `edge_type: "control"` in the kernel/runner shape
127
+ * and `type: "control"` / `data.edge_type: "control"` in the ReactFlow shape.
128
+ */
129
+ function isControlEdge(raw) {
130
+ return (raw.edge_type === "control" ||
131
+ raw.type === "control" ||
132
+ raw.data?.edge_type === "control");
133
+ }
134
+ function normalizeEdge(raw, index) {
135
+ return {
136
+ id: isString(raw.id) ? raw.id : `edge-${index}`,
137
+ source: String(raw.source ?? ""),
138
+ sourceHandle: String(raw.sourceHandle ?? raw.source_handle ?? ""),
139
+ target: String(raw.target ?? ""),
140
+ targetHandle: String(raw.targetHandle ?? raw.target_handle ?? ""),
141
+ isControl: isControlEdge(raw)
142
+ };
143
+ }
144
+ /**
145
+ * True for a model-reference value: a tagged object whose `type` ends in
146
+ * `_model` (`image_model`, `asr_model`, `tts_model`, `language_model`, …).
147
+ * Keying on that suffix avoids reacting to any unrelated object that happens
148
+ * to carry a `provider` field.
149
+ */
150
+ function isModelRef(value) {
151
+ // Null first, matching `asRecord` above: `typeof null === "object"`, so the
152
+ // check is load-bearing — without it `value.type` below throws on null.
153
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
154
+ return false;
155
+ }
156
+ const kind = value.type;
157
+ return typeof kind === "string" && kind.endsWith("_model");
158
+ }
159
+ /**
160
+ * How deep the property walk goes looking for model refs. A node's props are a
161
+ * shallow bag by convention; the depth exists for the shapes that nest one —
162
+ * a list of models, or a model inside a settings object — not to crawl payloads.
163
+ */
164
+ const MODEL_REF_MAX_DEPTH = 4;
165
+ /**
166
+ * Every model reference reachable from `value`.
167
+ *
168
+ * Checking only top-level property values missed the ones the type system
169
+ * allows anywhere: a `list[image_model]` property, or a model nested in a
170
+ * config object. Those fail exactly like a top-level one does — once the node
171
+ * runs — so they are worth the same check.
172
+ */
173
+ function collectModelRefs(value, path, propertyName, out, depth = 0) {
174
+ if (depth > MODEL_REF_MAX_DEPTH)
175
+ return;
176
+ if (Array.isArray(value)) {
177
+ value.forEach((item, index) => collectModelRefs(item, `${path}[${index}]`, propertyName, out, depth + 1));
178
+ return;
179
+ }
180
+ if (!isRecord(value))
181
+ return;
182
+ if (isModelRef(value)) {
183
+ out.push({ path, propertyName, ref: value });
184
+ return;
185
+ }
186
+ for (const [key, child] of Object.entries(value)) {
187
+ collectModelRefs(child, path ? `${path}.${key}` : key, propertyName, out, depth + 1);
188
+ }
189
+ }
190
+ /** Model references in one node's declared and dynamic property bags. */
191
+ function collectNodeModelRefs(node) {
192
+ const sites = [];
193
+ for (const [propName, value] of Object.entries(readProperties(node))) {
194
+ collectModelRefs(value, propName, propName, sites);
195
+ }
196
+ for (const [propName, value] of Object.entries(readDynamicProperties(node))) {
197
+ collectModelRefs(value, propName, propName, sites);
198
+ }
199
+ return sites;
200
+ }
201
+ /**
202
+ * Provider id of a model reference, or undefined when it names none. A blank
203
+ * provider is left to {@link collectModelSelectionIssues}, which reports it
204
+ * only when an id is present — an entirely empty ref is an unselected model,
205
+ * which the registry's own property check already covers.
206
+ */
207
+ function modelProviderOf(ref) {
208
+ const provider = ref.provider;
209
+ if (!isNonEmptyString(provider))
210
+ return undefined;
211
+ return provider;
212
+ }
213
+ /**
214
+ * The type an edge's source handle carries, reporting any handle it cannot
215
+ * resolve. An empty string means the edge is not type-checked at this end — no
216
+ * metadata, a reserved handle, or a dynamic output declaring no type.
217
+ */
218
+ function resolveSourceHandleType(edge, sourceNode, sourceMeta, issues) {
219
+ if (!sourceMeta ||
220
+ !edge.sourceHandle ||
221
+ isReservedHandle(edge.sourceHandle)) {
222
+ return "";
223
+ }
224
+ const declared = sourceMeta.outputs.find((o) => o.name === edge.sourceHandle);
225
+ if (declared)
226
+ return typeMetaToString(declared.type);
227
+ // Metadata, not instance state: `reactFlowNodeToGraphNode` writes
228
+ // `dynamic_outputs: {}` on every node, so testing the instance field
229
+ // disabled this check for nearly every saved graph. Mirrors the target
230
+ // side's use of `supports_dynamic_inputs`.
231
+ if (sourceMeta.supports_dynamic_outputs !== true) {
232
+ issues.push({
233
+ severity: "error",
234
+ code: "unknown_handle",
235
+ edgeId: edge.id,
236
+ nodeId: edge.source,
237
+ nodeType: String(sourceNode.type ?? ""),
238
+ message: `Edge "${edge.id}" references output "${edge.sourceHandle}" not found on ${String(sourceNode.type)}`
239
+ });
240
+ return "";
241
+ }
242
+ // An empty map is a node that never declared any dynamic output (the
243
+ // ReactFlow → graph conversion writes `{}` on every node), so only a
244
+ // populated one can say a handle is missing. A name the body emits counts as
245
+ // declared: the editor shows it and an agent can connect to it.
246
+ const dynamicOutputs = readDynamicOutputs(sourceNode);
247
+ const names = Object.keys(dynamicOutputs);
248
+ const inferred = isJsCodeNodeType(String(sourceNode.type ?? ""))
249
+ ? inferredCodeOutputNames(String(readProperties(sourceNode).code ?? ""))
250
+ : [];
251
+ if (names.length > 0 &&
252
+ !new Set([...names, ...inferred]).has(edge.sourceHandle)) {
253
+ issues.push({
254
+ severity: "error",
255
+ code: "unknown_handle",
256
+ edgeId: edge.id,
257
+ nodeId: edge.source,
258
+ nodeType: String(sourceNode.type ?? ""),
259
+ message: `Edge "${edge.id}" references output "${edge.sourceHandle}" on ${String(sourceNode.type)}, which declares ${names.map((n) => `"${n}"`).join(", ")}`
260
+ });
261
+ return "";
262
+ }
263
+ return typeMetaToString(dynamicOutputs[edge.sourceHandle]);
264
+ }
265
+ /**
266
+ * The type an edge's target handle expects — the mirror of
267
+ * {@link resolveSourceHandleType}, except that a dynamic slot declaring no
268
+ * type is reported rather than passed over in silence.
269
+ */
270
+ function resolveTargetHandleType(edge, targetNode, targetMeta, issues) {
271
+ if (!targetMeta ||
272
+ !edge.targetHandle ||
273
+ isReservedHandle(edge.targetHandle)) {
274
+ return { type: "", typedDynamicSlot: false };
275
+ }
276
+ const declared = targetMeta.properties.find((p) => p.name === edge.targetHandle);
277
+ if (declared) {
278
+ return { type: typeMetaToString(declared.type), typedDynamicSlot: false };
279
+ }
280
+ if (targetMeta.supports_dynamic_inputs !== true) {
281
+ issues.push({
282
+ severity: "error",
283
+ code: "unknown_handle",
284
+ edgeId: edge.id,
285
+ nodeId: edge.target,
286
+ nodeType: String(targetNode.type ?? ""),
287
+ message: `Edge "${edge.id}" targets input "${edge.targetHandle}" not found on ${String(targetNode.type)}`
288
+ });
289
+ return { type: "", typedDynamicSlot: false };
290
+ }
291
+ const slotType = slotTypeToString(readDynamicInputs(targetNode)[edge.targetHandle]);
292
+ if (slotType) {
293
+ return { type: slotType, typedDynamicSlot: true };
294
+ }
295
+ issues.push({
296
+ severity: "info",
297
+ code: "untyped_dynamic_slot",
298
+ edgeId: edge.id,
299
+ nodeId: edge.target,
300
+ nodeType: String(targetNode.type ?? ""),
301
+ message: `Edge "${edge.id}" targets dynamic input "${edge.targetHandle}" on ${String(targetNode.type)}, which declares no type — the connection is not type-checked`
302
+ });
303
+ return { type: "", typedDynamicSlot: false };
304
+ }
305
+ /**
306
+ * Self-loops and cycles — both fatal at run time, neither visible before it.
307
+ *
308
+ * `Graph.validateEdgeEndpoints` (kernel) rejects a self-loop on *any* edge:
309
+ * the node waits on an input only its own completion can close, so the run
310
+ * hangs. `analyzeCorrelation` requires the **data** subgraph to be a DAG, and
311
+ * its topological sort skips control edges — so cycles are looked for over
312
+ * data edges only, matching the kernel exactly.
313
+ *
314
+ * Kahn's algorithm, iterative: a deep graph must not blow the stack.
315
+ */
316
+ function detectCycles(byId, normEdges) {
317
+ const issues = [];
318
+ const selfLooped = new Set();
319
+ for (const e of normEdges) {
320
+ if (e.source !== e.target || !byId.has(e.source))
321
+ continue;
322
+ if (selfLooped.has(e.source))
323
+ continue;
324
+ selfLooped.add(e.source);
325
+ issues.push({
326
+ severity: "error",
327
+ code: "cycle",
328
+ nodeId: e.source,
329
+ nodeType: String(byId.get(e.source)?.type ?? ""),
330
+ edgeId: e.id,
331
+ message: `Node "${e.source}" is connected to itself by edge "${e.id}"; self-loop edges are not supported and deadlock the run`
332
+ });
333
+ }
334
+ const incoming = new Map();
335
+ const outgoing = new Map();
336
+ for (const id of byId.keys()) {
337
+ incoming.set(id, 0);
338
+ outgoing.set(id, []);
339
+ }
340
+ for (const e of normEdges) {
341
+ if (e.isControl)
342
+ continue;
343
+ if (e.source === e.target)
344
+ continue;
345
+ if (!byId.has(e.source) || !byId.has(e.target))
346
+ continue;
347
+ if (isLoopFeedbackHandle(String(byId.get(e.target)?.type ?? ""), e.targetHandle)) {
348
+ continue;
349
+ }
350
+ outgoing.get(e.source).push(e.target);
351
+ incoming.set(e.target, (incoming.get(e.target) ?? 0) + 1);
352
+ }
353
+ const queue = [];
354
+ for (const [id, count] of incoming) {
355
+ if (count === 0)
356
+ queue.push(id);
357
+ }
358
+ let ordered = 0;
359
+ // Index instead of shift(): shift() on a large array is O(n) per call.
360
+ for (let head = 0; head < queue.length; head++) {
361
+ ordered++;
362
+ for (const target of outgoing.get(queue[head]) ?? []) {
363
+ const next = (incoming.get(target) ?? 0) - 1;
364
+ incoming.set(target, next);
365
+ if (next === 0)
366
+ queue.push(target);
367
+ }
368
+ }
369
+ if (ordered < byId.size) {
370
+ const seen = new Set(queue);
371
+ const remaining = [...byId.keys()].filter((id) => !seen.has(id));
372
+ issues.push({
373
+ severity: "error",
374
+ code: "cycle",
375
+ nodeId: remaining[0],
376
+ nodeType: String(byId.get(remaining[0])?.type ?? ""),
377
+ message: `Cycle detected in graph; a cycle may only close on the "next" or "condition" input of a Loop node. Involved nodes: ${remaining.join(", ")}`
378
+ });
379
+ }
380
+ return issues;
381
+ }
382
+ /**
383
+ * Model id of a model reference. A blank id is an unselected model, which the
384
+ * registry's own property check already reports.
385
+ */
386
+ function modelIdOf(ref) {
387
+ const id = ref.id;
388
+ if (!isNonEmptyString(id))
389
+ return undefined;
390
+ return id;
391
+ }
392
+ /**
393
+ * The `_model` type tag of a model reference. Which catalog an id has to appear
394
+ * in depends on it: a provider's ASR ids and its image ids are separate lists,
395
+ * and only some are knowable offline.
396
+ */
397
+ function modelKindOf(ref) {
398
+ return String(ref.type);
399
+ }
400
+ /**
401
+ * The few known ids closest to `id`, to turn "not a model" into a fix. Manifest
402
+ * ids are long and slash-separated (`fal-ai/ltx-2/image-to-video/fast`), so a
403
+ * shared prefix locates a near-miss far better than edit distance over the
404
+ * whole string; ties break toward the closest length.
405
+ */
406
+ function nearestModelIds(id, known, limit = 3) {
407
+ const needle = id.toLowerCase();
408
+ const prefixLen = (candidate) => {
409
+ const other = candidate.toLowerCase();
410
+ let i = 0;
411
+ while (i < needle.length && i < other.length && needle[i] === other[i])
412
+ i++;
413
+ return i;
414
+ };
415
+ return ([...known]
416
+ .map((candidate) => ({ candidate, score: prefixLen(candidate) }))
417
+ // A single shared character is noise, not a suggestion.
418
+ .filter((entry) => entry.score >= 3)
419
+ .sort((a, b) => b.score - a.score ||
420
+ Math.abs(a.candidate.length - id.length) -
421
+ Math.abs(b.candidate.length - id.length) ||
422
+ a.candidate.localeCompare(b.candidate))
423
+ .slice(0, limit)
424
+ .map((entry) => entry.candidate));
425
+ }
426
+ /**
427
+ * Every provider/model id in `graph` that the runtime cannot honour.
428
+ *
429
+ * Split out of {@link validateGraph} so a caller that wants only this — a run
430
+ * preflight, an agent about to save a graph — pays for a property walk instead
431
+ * of a full graph validation, and so all of them report the same thing. The
432
+ * checks stay in `validateGraph` too; this is the same code, not a second
433
+ * opinion.
434
+ *
435
+ * Both checks fail toward silence. An empty provider list means the registry
436
+ * could not be reached, not that zero providers exist; an undefined model list
437
+ * means the catalog is only knowable online. Neither is evidence of a bad id.
438
+ */
439
+ export function collectModelSelectionIssues(graph, registry) {
440
+ const providerIds = registry.listProviderIds?.();
441
+ const knownProviders = providerIds && providerIds.length > 0 ? new Set(providerIds) : null;
442
+ if (!knownProviders)
443
+ return [];
444
+ const issues = [];
445
+ for (const node of graph.nodes ?? []) {
446
+ const nodeId = String(node.id ?? "");
447
+ const nodeType = String(node.type ?? "");
448
+ for (const { path, ref } of collectNodeModelRefs(node)) {
449
+ const modelId = modelIdOf(ref);
450
+ const provider = modelProviderOf(ref);
451
+ if (provider === undefined) {
452
+ // No provider and no id is an unselected model — the registry's own
453
+ // property check reports that one. An id without a provider is
454
+ // different: it names a model nothing can route to.
455
+ if (modelId !== undefined) {
456
+ issues.push({
457
+ severity: "error",
458
+ code: "missing_provider",
459
+ nodeId,
460
+ nodeType,
461
+ message: `Property "${path}" selects model "${modelId}" but names no ` +
462
+ "provider; the runtime cannot resolve a model id on its own"
463
+ });
464
+ }
465
+ continue;
466
+ }
467
+ if (!knownProviders.has(provider)) {
468
+ issues.push({
469
+ severity: "error",
470
+ code: "unknown_provider",
471
+ nodeId,
472
+ nodeType,
473
+ message: `Property "${path}" selects provider "${provider}", which is ` +
474
+ `not registered. Known providers: ${[...knownProviders]
475
+ .sort()
476
+ .join(", ")}`
477
+ });
478
+ continue;
479
+ }
480
+ // The provider is real; the id it names may still not be. A model id is
481
+ // only checkable where the catalog is known offline — undefined from the
482
+ // registry means unknown, so nothing is reported.
483
+ if (modelId === undefined)
484
+ continue;
485
+ const knownModels = registry.listModelIds?.(provider, modelKindOf(ref));
486
+ if (!knownModels || knownModels.length === 0)
487
+ continue;
488
+ if (knownModels.includes(modelId))
489
+ continue;
490
+ const suggestions = nearestModelIds(modelId, knownModels);
491
+ issues.push({
492
+ severity: "error",
493
+ code: "unknown_model",
494
+ nodeId,
495
+ nodeType,
496
+ message: `Property "${path}" selects model "${modelId}", which provider ` +
497
+ `"${provider}" does not offer.` +
498
+ (suggestions.length > 0
499
+ ? ` Did you mean: ${suggestions.join(", ")}?`
500
+ : "")
501
+ });
502
+ }
503
+ }
504
+ return issues;
505
+ }
506
+ /** A property value that reaches a provider as "the caller set nothing". */
507
+ function isBlankPropertyValue(value) {
508
+ if (value === undefined || value === null)
509
+ return true;
510
+ if (isString(value))
511
+ return value.trim().length === 0;
512
+ if (Array.isArray(value))
513
+ return value.length === 0;
514
+ return false;
515
+ }
516
+ /**
517
+ * Properties a node leaves blank that the model it selects declares it will not
518
+ * run without.
519
+ *
520
+ * A generic media node takes one shape and routes it to whichever provider the
521
+ * `model` property names, and those providers disagree about what is optional:
522
+ * Veo 3.1 animates an image with no prompt, Kling refuses the same call with
523
+ * `500 This field is required` — no field named, no node named, after the
524
+ * upstream half of the graph has been paid for. The requirement is already
525
+ * written down in the generated provider manifests; this is the check that
526
+ * reads it while the graph is still being authored.
527
+ *
528
+ * Reported at `info`, which is the honest severity: the graph is not wrong, it
529
+ * is *model-specific*. Pinned to Veo it runs; the finding is that swapping the
530
+ * model breaks it. `info` also keeps it below the `--warnings-as-errors`
531
+ * ratchet the shipped-examples gate runs at, so a generated manifest that marks
532
+ * something required by mistake cannot fail a build over a graph that works.
533
+ *
534
+ * Only where every part of the answer is known: the provider must enumerate its
535
+ * requirements offline, the node must declare a property of that name, nothing
536
+ * may feed it, and the model reference must be the node's own top-level
537
+ * property. Anything less is silence.
538
+ */
539
+ function collectRequiredModelInputIssues(nodeId, nodeType, node, declaredProperties, connectedHandles, registry) {
540
+ if (!registry.listRequiredTextInputs)
541
+ return [];
542
+ const properties = readProperties(node);
543
+ const issues = [];
544
+ const reported = new Set();
545
+ for (const { path, propertyName, ref } of collectNodeModelRefs(node)) {
546
+ // A model nested in a settings object or a list configures something other
547
+ // than this node's own inputs, so its requirements say nothing about them.
548
+ if (path !== propertyName)
549
+ continue;
550
+ const provider = modelProviderOf(ref);
551
+ const modelId = modelIdOf(ref);
552
+ if (provider === undefined || modelId === undefined)
553
+ continue;
554
+ const required = registry.listRequiredTextInputs(provider, modelKindOf(ref), modelId);
555
+ if (!required)
556
+ continue;
557
+ for (const name of required) {
558
+ if (reported.has(name))
559
+ continue;
560
+ if (!declaredProperties.has(name))
561
+ continue;
562
+ if (connectedHandles.has(name))
563
+ continue;
564
+ if (!isBlankPropertyValue(properties[name]))
565
+ continue;
566
+ reported.add(name);
567
+ issues.push({
568
+ severity: "info",
569
+ code: "missing_required_model_input",
570
+ nodeId,
571
+ nodeType,
572
+ message: `Node "${nodeId}" leaves "${name}" empty, and model "${modelId}" ` +
573
+ `(${provider}) declares it required. Set "${name}" or wire it — ` +
574
+ "until then, swapping this node's model breaks the graph."
575
+ });
576
+ }
577
+ }
578
+ return issues;
579
+ }
580
+ /**
581
+ * Every credential name a graph's nodes declare, deduplicated per node.
582
+ *
583
+ * Split out of {@link collectMissingSecretIssues} so a host can resolve the
584
+ * names against its store first and hand back only the availability set —
585
+ * validation stays synchronous and store-shaped knowledge stays with the
586
+ * host that owns it.
587
+ */
588
+ export function collectSecretRequirementSites(graph, registry) {
589
+ const sites = [];
590
+ const seen = new Set();
591
+ for (const node of graph.nodes ?? []) {
592
+ const nodeId = String(node.id ?? "");
593
+ const nodeType = String(node.type ?? "");
594
+ if (!nodeType || !registry.has(nodeType))
595
+ continue;
596
+ const meta = registry.getMetadata(nodeType);
597
+ for (const key of meta?.required_settings ?? []) {
598
+ if (!isNonEmptyString(key))
599
+ continue;
600
+ const dedupeKey = `${nodeId}\u0000${key}`;
601
+ if (seen.has(dedupeKey))
602
+ continue;
603
+ seen.add(dedupeKey);
604
+ sites.push({ nodeId, nodeType, key, source: "required_setting" });
605
+ }
606
+ if (!isJsCodeNodeType(nodeType))
607
+ continue;
608
+ const declaredSecrets = readProperties(node).secrets;
609
+ if (!Array.isArray(declaredSecrets))
610
+ continue;
611
+ for (const value of declaredSecrets) {
612
+ if (!isNonEmptyString(value))
613
+ continue;
614
+ const dedupeKey = `${nodeId}\u0000${value}`;
615
+ if (seen.has(dedupeKey))
616
+ continue;
617
+ seen.add(dedupeKey);
618
+ sites.push({ nodeId, nodeType, key: value, source: "code_secret" });
619
+ }
620
+ }
621
+ return sites;
622
+ }
623
+ /**
624
+ * Nodes whose declared credentials the supplied set cannot resolve.
625
+ *
626
+ * Both sources warn rather than error. A `required_settings` entry reaches
627
+ * `process()` as an empty string when absent, and some nodes treat that as
628
+ * optional at call time (a HuggingFace token widens the model catalog but is
629
+ * not the only way in), so absence does not prove a failed run. The Code
630
+ * node's list scopes reads rather than requiring them, for the same reason.
631
+ * The certain failure — a provider constructed without the credential its
632
+ * registration requires — is checked where the answer is certain, in the
633
+ * run preflight (`@nodetool-ai/execution`), against the provider registry.
634
+ *
635
+ * Fails toward silence like every catalog here: no set, nothing reported.
636
+ */
637
+ export function collectMissingSecretIssues(graph, registry, availableSecrets) {
638
+ if (!availableSecrets)
639
+ return [];
640
+ const issues = [];
641
+ for (const site of collectSecretRequirementSites(graph, registry)) {
642
+ if (availableSecrets.has(site.key))
643
+ continue;
644
+ issues.push({
645
+ severity: "warning",
646
+ code: "missing_secret",
647
+ nodeId: site.nodeId,
648
+ nodeType: site.nodeType,
649
+ message: site.source === "code_secret"
650
+ ? `Node "${site.nodeId}" declares secret "${site.key}", which this ` +
651
+ "install cannot resolve — the body's reads of it will fail."
652
+ : `Node "${site.nodeId}" (${site.nodeType}) needs "${site.key}", ` +
653
+ "which this install cannot resolve. Set it in Settings → " +
654
+ "Credentials, or the node may fail when it runs."
655
+ });
656
+ }
657
+ return issues;
658
+ }
659
+ /**
660
+ * Distinct provider ids across every model reference in the graph, in walk
661
+ * order. The run preflight resolves each against the provider registry to
662
+ * refuse a graph whose credentials are missing before anything executes.
663
+ */
664
+ export function collectModelProviders(graph) {
665
+ const providers = new Set();
666
+ const connectedInputs = new Set((graph.edges ?? [])
667
+ .map(normalizeEdge)
668
+ .filter((edge) => !edge.isControl && edge.target && edge.targetHandle)
669
+ .map((edge) => `${edge.target}\u0000${edge.targetHandle}`));
670
+ for (const node of graph.nodes ?? []) {
671
+ const nodeId = String(node.id ?? "");
672
+ for (const { propertyName, ref } of collectNodeModelRefs(node)) {
673
+ if (connectedInputs.has(`${nodeId}\u0000${propertyName}`))
674
+ continue;
675
+ const provider = modelProviderOf(ref);
676
+ if (provider !== undefined)
677
+ providers.add(provider);
678
+ }
679
+ }
680
+ return [...providers];
681
+ }
682
+ /**
683
+ * Every pinned script link a graph's Code nodes carry, deduplicated. A host
684
+ * that wants the linked-script checks loads these first and hands
685
+ * {@link validateGraph} a lookup over the result.
686
+ */
687
+ export function collectJsScriptLinks(graph) {
688
+ const seen = new Map();
689
+ for (const node of graph.nodes ?? []) {
690
+ if (!isJsCodeNodeType(String(node.type ?? "")))
691
+ continue;
692
+ const link = readJsScriptLink(readProperties(node));
693
+ if (!link)
694
+ continue;
695
+ seen.set(`${link.id}@${link.version}`, link);
696
+ }
697
+ return [...seen.values()];
698
+ }
699
+ /**
700
+ * DSL wiring handles stored as property values. The builders mint these to
701
+ * wire an edge; one that survives into a bag means the connection was never
702
+ * made, so the input is unconnected and the node producing it may be missing
703
+ * from the graph entirely. Runs before the registry guard: the marker is
704
+ * shape-level, not type-level.
705
+ */
706
+ function collectLeftoverWiringHandleIssues(nodeId, nodeType, node) {
707
+ const issues = [];
708
+ for (const [propName, value] of Object.entries(readProperties(node))) {
709
+ const handlePaths = [];
710
+ collectWiringHandlePaths(value, propName, handlePaths);
711
+ for (const path of handlePaths) {
712
+ issues.push({
713
+ severity: "error",
714
+ code: "leftover_wiring_handle",
715
+ nodeId,
716
+ nodeType,
717
+ message: `Property "${path}" on node "${nodeId}" still holds a wiring ` +
718
+ "handle — the connection was never created, so the node " +
719
+ "producing this output may be missing from the graph. Re-wire " +
720
+ "it as an edge."
721
+ });
722
+ }
723
+ }
724
+ return issues;
725
+ }
726
+ /**
727
+ * Properties the node does not have. A misspelled name is dead data: the value
728
+ * sits in the graph, the real property keeps its default, and nothing reports
729
+ * it. A required property left unset is already an error elsewhere, so what
730
+ * reaches here is an optional one silently ignored — a warning, not a refusal.
731
+ * Nodes taking dynamic properties are exempt: an undeclared name is the
732
+ * feature there.
733
+ */
734
+ function collectUnknownPropertyIssues(nodeId, nodeType, node, meta) {
735
+ if (meta.supports_dynamic_inputs === true)
736
+ return [];
737
+ const declaredNames = new Set(meta.properties.map((prop) => prop.name));
738
+ const dynamic = readDynamicProperties(node);
739
+ const unknown = Object.keys(readProperties(node)).filter((name) => !declaredNames.has(name) &&
740
+ !isReservedHandle(name) &&
741
+ dynamic[name] === undefined);
742
+ if (unknown.length === 0)
743
+ return [];
744
+ const plural = unknown.length > 1;
745
+ const named = unknown.map((name) => `"${name}"`).join(", ");
746
+ const known = [...declaredNames].sort().join(", ");
747
+ return [
748
+ {
749
+ severity: "warning",
750
+ code: "unknown_property",
751
+ nodeId,
752
+ nodeType,
753
+ message: `${named} ${plural ? "are" : "is"} not a property of ${nodeType}, so ${plural ? "they are" : "it is"} ignored at run time.${known ? ` It takes: ${known}.` : ""}`
754
+ }
755
+ ];
756
+ }
757
+ /**
758
+ * Dynamic slot types: a JSON-Schema/TypeScript spelling of a type NodeTool
759
+ * already has passes the transport schema, then silently refuses to connect.
760
+ * Custom names stay legal — only known aliases are flagged. Inputs are
761
+ * reported before outputs.
762
+ */
763
+ function collectSlotTypeAliasIssues(nodeId, nodeType, node) {
764
+ const issues = [];
765
+ const check = (direction, slotName, slotType) => {
766
+ for (const alias of portTypeAliases(slotType)) {
767
+ issues.push({
768
+ severity: "error",
769
+ code: "slot_type_alias",
770
+ nodeId,
771
+ nodeType,
772
+ message: `Dynamic ${direction} "${slotName}" is typed "${alias.used}", which is not a ` +
773
+ `NodeTool type — use "${alias.canonical}". A handle typed with an unknown ` +
774
+ "name will not connect."
775
+ });
776
+ }
777
+ };
778
+ for (const [slotName, slot] of Object.entries(readDynamicInputs(node))) {
779
+ check("input", slotName, slot?.type);
780
+ }
781
+ for (const [slotName, slotType] of Object.entries(readDynamicOutputs(node))) {
782
+ check("output", slotName, slotType);
783
+ }
784
+ return issues;
785
+ }
786
+ /**
787
+ * A Code node's linked script: the node carries a *copy* of the pinned
788
+ * script's body, so the body itself is checked exactly like an inline one.
789
+ * What the link adds is a freshness question — does the node's shape still
790
+ * match the script it came from? Nothing here blocks a run, because a run no
791
+ * longer reads the script row.
792
+ */
793
+ function collectJsScriptLinkIssues(nodeId, nodeType, node, link, connectedInputs, lookup) {
794
+ const resolved = lookup ? lookup(link) : undefined;
795
+ if (!resolved) {
796
+ return [
797
+ {
798
+ severity: "warning",
799
+ code: lookup ? "js_script_missing" : "js_script_unverified",
800
+ nodeId,
801
+ nodeType,
802
+ message: `Node "${nodeId}" is linked to JS script ${link.id} v${link.version}, ` +
803
+ (lookup
804
+ ? "which no longer exists, so the link's freshness cannot be " +
805
+ "verified. The node runs the body it copied at link time."
806
+ : "whose freshness cannot be verified here — no script store is " +
807
+ "available. The node runs the body it copied at link time.")
808
+ }
809
+ ];
810
+ }
811
+ // Compare the script to slots the node actually stores. Names the body reads
812
+ // are implicit handles for the editor; they must not hide a missing stored
813
+ // slot on a linked node.
814
+ const nodeInputs = [
815
+ ...Object.keys(readDynamicInputs(node)),
816
+ ...Object.keys(readDynamicProperties(node)),
817
+ ...connectedInputs
818
+ ].filter((name) => !isReservedHandle(name));
819
+ return jsScriptPortMismatches({
820
+ scriptInputs: resolved.document.inputs.map((port) => port.name),
821
+ scriptOutputs: resolved.document.outputs.map((port) => port.name),
822
+ nodeInputs,
823
+ nodeOutputs: Object.keys(readDynamicOutputs(node))
824
+ }).map((mismatch) => ({
825
+ severity: "error",
826
+ code: "js_script_ports",
827
+ nodeId,
828
+ nodeType,
829
+ message: `Node "${nodeId}": ${mismatch}.`
830
+ }));
831
+ }
832
+ /**
833
+ * Code nodes: the body is only checked by running it, and a run costs the
834
+ * whole graph. Everything decidable from the graph is decided here.
835
+ */
836
+ function collectCodeNodeIssues(nodeId, nodeType, node, connectedHandles, consumedHandles, sandboxModuleCatalog, jsScriptLookup) {
837
+ const props = readProperties(node);
838
+ const code = String(props.code ?? "");
839
+ const connectedInputs = [...connectedHandles].filter((name) => !isReservedHandle(name));
840
+ // A named `inputs.foo` / `stream("foo")` read is a handle. The editor shows
841
+ // it; an agent can connect to it. Count it as declared so the body is not
842
+ // rejected for a slot the code itself just created.
843
+ const availableInputs = new Set([
844
+ ...Object.keys(readDynamicInputs(node)),
845
+ ...Object.keys(readDynamicProperties(node)),
846
+ ...connectedInputs,
847
+ ...inferredCodeInputNames(code)
848
+ ]);
849
+ const link = readJsScriptLink(props);
850
+ const issues = link
851
+ ? collectJsScriptLinkIssues(nodeId, nodeType, node, link, connectedInputs, jsScriptLookup)
852
+ : [];
853
+ for (const codeIssue of validateCodeNodeBody({
854
+ code: props.code,
855
+ availableInputs: [...availableInputs].filter((name) => !isReservedHandle(name)),
856
+ connectedInputs,
857
+ declaredOutputs: [
858
+ ...new Set([
859
+ ...Object.keys(readDynamicOutputs(node)),
860
+ ...inferredCodeOutputNames(code)
861
+ ])
862
+ ],
863
+ connectedOutputs: [...consumedHandles],
864
+ sandboxModuleCatalog
865
+ })) {
866
+ issues.push({
867
+ severity: codeIssue.severity,
868
+ code: codeIssue.code,
869
+ nodeId,
870
+ nodeType,
871
+ message: `Node "${nodeId}": ${codeIssue.message}`
872
+ });
873
+ }
874
+ return issues;
875
+ }
876
+ export function validateGraph(rawGraph, registry, options = {}) {
877
+ // Validate what the runner will execute. `Graph.loadFromDict` rewrites
878
+ // removed node types through NODE_TYPE_MIGRATIONS on every load, so a saved
879
+ // graph carrying one runs fine — but validating the raw graph reported the
880
+ // migrated-away type as `unknown_node` and refused it. Two shipped examples
881
+ // (text_transforms_cli, text_regex_parse_cli) sat in exactly that gap.
882
+ const graph = migrateGraphNodeTypes(rawGraph);
883
+ const sandboxModuleCatalog = options.sandboxModuleCatalog !== undefined
884
+ ? options.sandboxModuleCatalog
885
+ : getProcessSandboxModuleCatalog();
886
+ const issues = [];
887
+ const nodes = graphMembers(graph.nodes, "nodes", issues);
888
+ const edges = graphMembers(graph.edges, "edges", issues);
889
+ // ── Nodes: ids, types, properties ────────────────────────────────────────
890
+ const byId = new Map();
891
+ const seenIds = new Set();
892
+ for (const node of nodes) {
893
+ const id = String(node.id ?? "");
894
+ const type = String(node.type ?? "");
895
+ if (!id) {
896
+ // Nothing can address this node: no edge can name it, and the runner
897
+ // keys every message by node id.
898
+ issues.push({
899
+ severity: "error",
900
+ code: "missing_id",
901
+ nodeType: type,
902
+ message: `Node of type "${type || "(no type)"}" has no id`
903
+ });
904
+ }
905
+ if (id && seenIds.has(id)) {
906
+ issues.push({
907
+ severity: "error",
908
+ code: "duplicate_id",
909
+ nodeId: id,
910
+ nodeType: type,
911
+ message: `Duplicate node id "${id}"`
912
+ });
913
+ }
914
+ else if (id) {
915
+ seenIds.add(id);
916
+ byId.set(id, node);
917
+ }
918
+ if (!type) {
919
+ issues.push({
920
+ severity: "error",
921
+ code: "unknown_node",
922
+ nodeId: id,
923
+ message: `Node "${id}" has no type`
924
+ });
925
+ }
926
+ else if (!registry.has(type) && !isEditorOnlyType(type)) {
927
+ issues.push({
928
+ severity: "error",
929
+ code: "unknown_node",
930
+ nodeId: id,
931
+ nodeType: type,
932
+ message: legacyGameNodeDiagnostic(type) ?? `Unknown node type "${type}" (not in the registry; Python-only nodes are not validated statically)`
933
+ });
934
+ }
935
+ }
936
+ // Handles fed by an incoming edge get their value at runtime — don't flag
937
+ // them as missing required properties.
938
+ const connectedByNode = new Map();
939
+ // Source handles other nodes read — the output handles that have to exist.
940
+ const consumedByNode = new Map();
941
+ const normEdges = edges.map(normalizeEdge);
942
+ const addHandle = (map, nodeId, handle) => {
943
+ const set = map.get(nodeId);
944
+ if (set) {
945
+ set.add(handle);
946
+ }
947
+ else {
948
+ map.set(nodeId, new Set([handle]));
949
+ }
950
+ };
951
+ for (const e of normEdges) {
952
+ if (e.target && e.targetHandle) {
953
+ addHandle(connectedByNode, e.target, e.targetHandle);
954
+ }
955
+ if (e.source && e.sourceHandle && !isReservedHandle(e.sourceHandle)) {
956
+ addHandle(consumedByNode, e.source, e.sourceHandle);
957
+ }
958
+ }
959
+ // `byId` holds one node per id — a duplicate id is already an error, and
960
+ // validating the shadowed copy's properties would report everything twice.
961
+ for (const [id, node] of byId) {
962
+ const type = String(node.type ?? "");
963
+ const connected = connectedByNode.get(id) ?? new Set();
964
+ issues.push(...collectLeftoverWiringHandleIssues(id, type, node));
965
+ if (!type || !registry.has(type))
966
+ continue;
967
+ const propIssues = registry.validateNode({
968
+ id,
969
+ type,
970
+ properties: readProperties(node),
971
+ // Without these the registry cannot reach validateDynamicSlots, so a
972
+ // slot declared `required` is never checked.
973
+ dynamic_inputs: readDynamicInputs(node),
974
+ dynamic_properties: readDynamicProperties(node)
975
+ }, connected);
976
+ for (const pi of propIssues) {
977
+ issues.push({
978
+ severity: "error",
979
+ code: "property",
980
+ nodeId: id,
981
+ nodeType: type,
982
+ message: pi.message
983
+ });
984
+ }
985
+ const meta = registry.getMetadata(type);
986
+ if (meta) {
987
+ // Properties blank that the selected model declares required. The
988
+ // registry's own check above knows which properties this node requires;
989
+ // it cannot know which ones the model behind `model` requires.
990
+ issues.push(...collectRequiredModelInputIssues(id, type, node, new Set(meta.properties.map((prop) => prop.name)), connected, registry), ...collectUnknownPropertyIssues(id, type, node, meta));
991
+ }
992
+ issues.push(...collectSlotTypeAliasIssues(id, type, node));
993
+ // A `code` handle fed by an edge carries a body nothing here can see.
994
+ if (isJsCodeNodeType(type) && !connected.has("code")) {
995
+ issues.push(...collectCodeNodeIssues(id, type, node, connected, consumedByNode.get(id) ?? new Set(), sandboxModuleCatalog, options.jsScriptLookup));
996
+ }
997
+ }
998
+ // A model property naming a provider the runtime cannot construct, or an id
999
+ // that provider does not offer, fails only once the node runs — after the
1000
+ // rest of the graph has already been paid for. Both are right there in the
1001
+ // saved properties.
1002
+ issues.push(...collectModelSelectionIssues({ nodes: [...byId.values()] }, registry));
1003
+ // Declared credentials the supplied availability set cannot resolve — a
1004
+ // warning per site, and nothing at all when no set was supplied.
1005
+ issues.push(...collectMissingSecretIssues({ nodes: [...byId.values()] }, registry, options.availableSecrets));
1006
+ // ── Fan-in: >1 edge into a handle is only legal when the handle's declared
1007
+ // type is a list. Mirrors the kernel's correlation-analysis rule so an
1008
+ // example that validates cannot die at run time on exactly this.
1009
+ const fanIn = new Map();
1010
+ for (const e of normEdges) {
1011
+ // Control edges carry no data — `analyzeCorrelation` filters them out
1012
+ // before counting, so counting them here would invent errors.
1013
+ if (e.isControl)
1014
+ continue;
1015
+ if (!e.target || !e.targetHandle)
1016
+ continue;
1017
+ const key = `${e.target}\u0000${e.targetHandle}`;
1018
+ fanIn.set(key, (fanIn.get(key) ?? 0) + 1);
1019
+ }
1020
+ for (const [key, count] of fanIn) {
1021
+ if (count < 2)
1022
+ continue;
1023
+ const [targetId, handle] = key.split("\u0000");
1024
+ const node = byId.get(targetId);
1025
+ const type = String(node?.type ?? "");
1026
+ if (!type || !registry.has(type))
1027
+ continue;
1028
+ const propType = registry
1029
+ .getMetadata(type)
1030
+ ?.properties?.find((prop) => prop.name === handle)?.type;
1031
+ // A dynamic slot is not a static property. The kernel merges declared slot
1032
+ // types into `propertyTypes` (Graph.loadFromDict), so a slot declared
1033
+ // `list[...]` is a legal fan-in target — resolve it the same way here.
1034
+ const typeStr = typeMetaToString(propType) ||
1035
+ slotTypeToString(node ? readDynamicInputs(node)[handle] : undefined);
1036
+ if (!(typeStr === "list" || typeStr.startsWith("list["))) {
1037
+ issues.push({
1038
+ severity: "error",
1039
+ code: "fan_in",
1040
+ nodeId: targetId,
1041
+ nodeType: type,
1042
+ message: `Handle "${handle}" on node "${targetId}" receives ${count} edges but its ` +
1043
+ `type "${typeStr || "unknown"}" is not a list; the kernel's correlation ` +
1044
+ `analysis rejects this at run time`
1045
+ });
1046
+ }
1047
+ }
1048
+ // ── Cycles: only Loop feedback edges may close one ───────────────────────
1049
+ issues.push(...detectCycles(byId, normEdges));
1050
+ // ── Edges: endpoints, handles, type compatibility ────────────────────────
1051
+ for (const e of normEdges) {
1052
+ const sourceNode = byId.get(e.source);
1053
+ const targetNode = byId.get(e.target);
1054
+ if (!sourceNode) {
1055
+ issues.push({
1056
+ severity: "error",
1057
+ code: "dangling_edge",
1058
+ edgeId: e.id,
1059
+ message: `Edge "${e.id}" source node "${e.source}" does not exist`
1060
+ });
1061
+ }
1062
+ if (!targetNode) {
1063
+ issues.push({
1064
+ severity: "error",
1065
+ code: "dangling_edge",
1066
+ edgeId: e.id,
1067
+ message: `Edge "${e.id}" target node "${e.target}" does not exist`
1068
+ });
1069
+ }
1070
+ if (!sourceNode || !targetNode)
1071
+ continue;
1072
+ // Both endpoints exist but the edge names no handle: the kernel routes
1073
+ // messages by `${source}:${sourceHandle}`, so an empty handle matches
1074
+ // nothing and the edge silently carries no data. The handle checks below
1075
+ // are all gated on a non-empty handle, so without this the edge would
1076
+ // validate clean.
1077
+ if (!e.sourceHandle) {
1078
+ issues.push({
1079
+ severity: "error",
1080
+ code: "missing_handle",
1081
+ edgeId: e.id,
1082
+ nodeId: e.source,
1083
+ nodeType: String(sourceNode.type ?? ""),
1084
+ message: `Edge "${e.id}" names no source handle on node "${e.source}" — it cannot be routed`
1085
+ });
1086
+ }
1087
+ if (!e.targetHandle) {
1088
+ issues.push({
1089
+ severity: "error",
1090
+ code: "missing_handle",
1091
+ edgeId: e.id,
1092
+ nodeId: e.target,
1093
+ nodeType: String(targetNode.type ?? ""),
1094
+ message: `Edge "${e.id}" names no target handle on node "${e.target}" — it cannot be routed`
1095
+ });
1096
+ }
1097
+ const sourceType = resolveSourceHandleType(e, sourceNode, registry.getMetadata(String(sourceNode.type ?? "")), issues);
1098
+ const target = resolveTargetHandleType(e, targetNode, registry.getMetadata(String(targetNode.type ?? "")), issues);
1099
+ if (typesIncompatible(sourceType, target.type)) {
1100
+ issues.push({
1101
+ severity: target.typedDynamicSlot ? "error" : "warning",
1102
+ code: "type_mismatch",
1103
+ edgeId: e.id,
1104
+ nodeId: e.target,
1105
+ nodeType: String(targetNode.type ?? ""),
1106
+ message: target.typedDynamicSlot
1107
+ ? `Edge "${e.id}" connects ${String(sourceNode.type)}.${e.sourceHandle} (${sourceType}) → dynamic input ${String(targetNode.type)}.${e.targetHandle}, declared as ${target.type}`
1108
+ : `Edge "${e.id}" connects ${String(sourceNode.type)}.${e.sourceHandle} (${sourceType}) → ${String(targetNode.type)}.${e.targetHandle} (${target.type}) — types may be incompatible`
1109
+ });
1110
+ }
1111
+ }
1112
+ // ── Inline dynamic values against their declared slot types ──────────────
1113
+ for (const node of nodes) {
1114
+ const slots = readDynamicInputs(node);
1115
+ const slotNames = Object.keys(slots);
1116
+ if (slotNames.length === 0)
1117
+ continue;
1118
+ const id = String(node.id ?? "");
1119
+ const values = readDynamicProperties(node);
1120
+ const connected = connectedByNode.get(id);
1121
+ for (const name of slotNames) {
1122
+ if (connected?.has(name))
1123
+ continue;
1124
+ const typeStr = slotTypeToString(slots[name]);
1125
+ if (valueIncompatibleWithType(values[name], typeStr)) {
1126
+ issues.push({
1127
+ severity: "warning",
1128
+ code: "dynamic_type_mismatch",
1129
+ nodeId: id,
1130
+ nodeType: String(node.type ?? ""),
1131
+ message: `Dynamic input "${name}" on node "${id}" is declared as ${typeStr} but holds a ${typeof values[name]}`
1132
+ });
1133
+ }
1134
+ }
1135
+ }
1136
+ let errors = 0;
1137
+ let warnings = 0;
1138
+ let info = 0;
1139
+ for (const i of issues) {
1140
+ if (i.severity === "error")
1141
+ errors++;
1142
+ else if (i.severity === "warning")
1143
+ warnings++;
1144
+ else if (i.severity === "info")
1145
+ info++;
1146
+ }
1147
+ return {
1148
+ ok: errors === 0,
1149
+ nodeCount: nodes.length,
1150
+ edgeCount: edges.length,
1151
+ counts: { errors, warnings, info },
1152
+ issues
1153
+ };
1154
+ }
1155
+ /** One-line summary headline for human output. */
1156
+ export function validationHeadline(report) {
1157
+ if (report.ok && report.counts.warnings === 0) {
1158
+ return `Workflow is valid — ${report.nodeCount} node(s), ${report.edgeCount} edge(s).`;
1159
+ }
1160
+ if (report.ok) {
1161
+ return `Workflow is valid with ${report.counts.warnings} warning(s).`;
1162
+ }
1163
+ return `Workflow has ${report.counts.errors} error(s)${report.counts.warnings ? ` and ${report.counts.warnings} warning(s)` : ""}.`;
1164
+ }
1165
+ //# sourceMappingURL=graph-validation.js.map