@nodetool-ai/node-sdk 0.7.0-rc.23 → 0.8.2

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