@unoverse-platform/studio 0.1.89 → 0.1.91

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 (132) hide show
  1. package/local/plugin.ts +20 -12
  2. package/package.json +13 -3
  3. package/screens/TemplatesLibraryView.tsx +31 -6
  4. package/screens/TemplatesView.tsx +1 -1
  5. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  6. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  7. package/vendor/base/src/agent-mcp/index.ts +44 -0
  8. package/vendor/base/src/agent-mcp/invoke.ts +140 -0
  9. package/vendor/base/src/agent-mcp/lean.ts +111 -0
  10. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  11. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  12. package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
  13. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  14. package/vendor/base/src/agent-mcp/types.ts +130 -0
  15. package/vendor/base/src/auth/auth.ts +278 -0
  16. package/vendor/base/src/auth/authConfig.ts +73 -0
  17. package/vendor/base/src/boot.ts +245 -0
  18. package/vendor/base/src/definitions/definitions.ts +18 -0
  19. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  20. package/vendor/base/src/definitions/fsCache.ts +128 -0
  21. package/vendor/base/src/definitions/index.ts +19 -0
  22. package/vendor/base/src/definitions/inputs.ts +30 -0
  23. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  24. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  25. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  26. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  27. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  28. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  29. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  30. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  31. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  32. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  33. package/vendor/base/src/definitions/load/load.ts +487 -0
  34. package/vendor/base/src/definitions/model.ts +238 -0
  35. package/vendor/base/src/definitions/registry/catalog.ts +643 -0
  36. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  37. package/vendor/base/src/definitions/theme.ts +383 -0
  38. package/vendor/base/src/executor.ts +284 -0
  39. package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
  40. package/vendor/base/src/items/baseVersion.ts +96 -0
  41. package/vendor/base/src/items/collect.ts +199 -0
  42. package/vendor/base/src/items/fingerprint.ts +34 -0
  43. package/vendor/base/src/items/publish.ts +198 -0
  44. package/vendor/base/src/lint/context.mjs +56 -0
  45. package/vendor/base/src/lint/design/defs.mjs +46 -0
  46. package/vendor/base/src/lint/design/file.mjs +737 -0
  47. package/vendor/base/src/lint/design/index.d.mts +30 -0
  48. package/vendor/base/src/lint/design/index.mjs +996 -0
  49. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  50. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  51. package/vendor/base/src/lint/design/walk.mjs +444 -0
  52. package/vendor/base/src/lint/index.mjs +28 -0
  53. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  54. package/vendor/base/src/lint/nodes/index.mjs +64 -0
  55. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  56. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  57. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  58. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  59. package/vendor/base/src/logger.ts +33 -0
  60. package/vendor/base/src/manifests/compose.ts +539 -0
  61. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  62. package/vendor/base/src/manifests/executor/context.ts +107 -0
  63. package/vendor/base/src/manifests/executor/index.ts +47 -0
  64. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  65. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  66. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  67. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  68. package/vendor/base/src/manifests/executor/send.ts +65 -0
  69. package/vendor/base/src/manifests/executor/session.ts +46 -0
  70. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  71. package/vendor/base/src/manifests/index.ts +280 -0
  72. package/vendor/base/src/manifests/integrity.ts +93 -0
  73. package/vendor/base/src/manifests/rows.ts +33 -0
  74. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  75. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  76. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  77. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  78. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  79. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  80. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  81. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  82. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  83. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  84. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  85. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  86. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  87. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  88. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  89. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  90. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  91. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  92. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  93. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  94. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  95. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  96. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  97. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  98. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  99. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  100. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  101. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  102. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  103. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  104. package/vendor/base/src/manifests/source.ts +187 -0
  105. package/vendor/base/src/manifests/watch.ts +67 -0
  106. package/vendor/base/src/markdown/document.ts +141 -0
  107. package/vendor/base/src/paths.ts +180 -0
  108. package/vendor/base/src/platform/audioManager.ts +100 -0
  109. package/vendor/base/src/platform/clientTransport.ts +65 -0
  110. package/vendor/base/src/platform/executionContext.ts +154 -0
  111. package/vendor/base/src/platform/index.ts +10 -0
  112. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  113. package/vendor/base/src/platform/redis.ts +54 -0
  114. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  115. package/vendor/base/src/pluginBase.ts +248 -0
  116. package/vendor/base/src/plugins/README.md +57 -0
  117. package/vendor/base/src/plugins/discovery.ts +87 -0
  118. package/vendor/base/src/plugins/index.ts +16 -0
  119. package/vendor/base/src/plugins/loader.ts +179 -0
  120. package/vendor/base/src/plugins/redis.ts +36 -0
  121. package/vendor/base/src/plugins/state.ts +53 -0
  122. package/vendor/base/src/registry.ts +95 -0
  123. package/vendor/base/src/security/permissions.ts +93 -0
  124. package/vendor/base/src/security/rules.ts +73 -0
  125. package/vendor/base/src/template/SafeExpression.ts +556 -0
  126. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  127. package/vendor/base/src/template/index.ts +22 -0
  128. package/vendor/base/src/types.ts +50 -0
  129. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  130. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  131. package/vendor/base/src/workflow/routing.ts +122 -0
  132. package/vendor/sdk/lib/core/store.ts +26 -6
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Every rule that applies to ONE node folder.
3
+ *
4
+ * Tier 2 is structure (does it match the schema); tier 3 is everything a schema cannot
5
+ * express: agreement BETWEEN files, the events table matching the output connectors,
6
+ * chained calls being reachable by name, credentials existing in the package that needs
7
+ * them, whenToUse carrying real selection signal.
8
+ *
9
+ * The rules stay in one function because they share the composed node: splitting them
10
+ * further would mean rebuilding `parts` several times, or threading a dozen locals
11
+ * between modules, and neither is more readable than this.
12
+ */
13
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
14
+ import { join, relative, resolve, dirname } from "node:path";
15
+ import { report, rel, refCounts, derivedKinds, allCredentials, state } from "../context.mjs";
16
+ import { SCHEMA_ID, SECTIONS, validateAgainst, Validator } from "./schema.mjs";
17
+ import { readYaml, resolveRefs, sharedDirFor } from "./load.mjs";
18
+ import { HANDLEBARS_HELPERS, promptBlocks } from "./blocks.mjs";
19
+
20
+ export function lintNode(dir, pkg) {
21
+ const nodePath = join(dir, "node.yaml");
22
+ const label = rel(dir);
23
+
24
+ if (!existsSync(nodePath))
25
+ return report("error", label, `has no node.yaml. It is the one REQUIRED file in a node folder (DECLARATIVE_NODES.md §5)`);
26
+
27
+ const node = readYaml(nodePath);
28
+ if (!node) return;
29
+
30
+ // Each section: its own file, or inline in node.yaml, NEVER both. Silently
31
+ // merging the two would make a stale file invisible.
32
+ const parts = {};
33
+ for (const s of SECTIONS) {
34
+ const file = join(dir, `${s}.yaml`);
35
+ const folder = join(dir, s);
36
+ const hasFolder = existsSync(folder) && statSync(folder).isDirectory();
37
+ const onDisk = existsSync(file);
38
+ const inline = node[s] !== undefined;
39
+
40
+ const ways = [hasFolder ? `${s}/` : null, onDisk ? `${s}.yaml` : null, inline ? "node.yaml" : null].filter(Boolean);
41
+ if (ways.length > 1) {
42
+ report("error", rel(hasFolder ? folder : file), `"${s}" is defined in ${ways.join(" and ")}. Pick one; this is never a merge (DECLARATIVE_NODES.md §5)`);
43
+ continue;
44
+ }
45
+
46
+ // `api` is always the folder form, so every node reads the same way.
47
+ if (s === "api" && (onDisk || inline)) {
48
+ report("error", onDisk ? rel(file) : rel(nodePath), `api must be a FOLDER: api/run.yaml, api/events.yaml, and one file per further part (api/service.yaml, api/toolExchange.yaml, api/narrate.yaml). Every node has the same shape (DECLARATIVE_NODES.md §5)`);
49
+ continue;
50
+ }
51
+
52
+ if (hasFolder) {
53
+ // One file per top-level key, named for the key it holds. Assembled here so
54
+ // every rule below reads the same shape whichever layout the author chose.
55
+ const files = readdirSync(folder).filter((f) => /\.ya?ml$/.test(f)).sort();
56
+ if (!files.length) {
57
+ report("error", rel(folder), `is an empty ${s}/ folder. Delete it, or add one file per top-level key (DECLARATIVE_NODES.md §5)`);
58
+ continue;
59
+ }
60
+ const doc = {};
61
+ for (const f of files) {
62
+ const key = f.replace(/\.ya?ml$/, "");
63
+ const part = readYaml(join(folder, f));
64
+ if (part === undefined || part === null) continue;
65
+ // The filename IS the key, so a file that repeats it nests the block twice.
66
+ if (typeof part === "object" && !Array.isArray(part) && Object.keys(part).length === 1 && key in part)
67
+ report("error", rel(join(folder, f)), `starts with "${key}:" again. The FILENAME is the key, so this file holds the CONTENTS of ${key} (DECLARATIVE_NODES.md §5)`);
68
+ doc[key] = part;
69
+ }
70
+ parts[s] = { doc, file: rel(folder) };
71
+ } else if (onDisk) parts[s] = { doc: readYaml(file), file: rel(file) };
72
+ else if (inline) parts[s] = { doc: node[s], file: rel(nodePath) };
73
+ }
74
+ for (const s of SECTIONS) if (parts[s]) delete node[s];
75
+
76
+ // Resolve $refs into shared/ before anything reads a value.
77
+ for (const s of SECTIONS)
78
+ if (parts[s]?.doc) parts[s].doc = resolveRefs(parts[s].doc, dir, parts[s].file);
79
+
80
+ // ── tier 2: structure ──
81
+ validateAgainst(SCHEMA_ID.node, node, rel(nodePath), "node.schema.json");
82
+ for (const s of SECTIONS) if (parts[s]?.doc) validateAgainst(SCHEMA_ID[s], parts[s].doc, parts[s].file, `${s}.schema.json`);
83
+
84
+ const iface = parts.interface?.doc ?? {};
85
+ const config = parts.config?.doc ?? null;
86
+ const api = parts.api?.doc ?? null;
87
+ const test = parts.test?.doc ?? null;
88
+ const F = { node: rel(nodePath), iface: parts.interface?.file, config: parts.config?.file, api: parts.api?.file, test: parts.test?.file };
89
+
90
+ const outputs = new Set((iface.outputs ?? []).map((o) => o.name));
91
+ const inputs = new Set((iface.inputs ?? []).map((i) => i.name));
92
+
93
+ /**
94
+ * EVERY call this node makes, wherever it is declared, each with a label saying where.
95
+ *
96
+ * The rules that matter most are per-CALL, not per-node: the allowedHosts boundary, https,
97
+ * and the capabilities package.yaml must promise. Those all used to read `api.request`
98
+ * alone, so a host named only in `narrate.request` or in a `service` method was never
99
+ * checked against the allowedHosts list at all. Since allowedHosts is the security boundary for a
100
+ * format whose whole premise is that a manifest may arrive as data, a rule that skips
101
+ * four of the five places a URL can appear is not a rule.
102
+ *
103
+ * Widened here rather than by adding a fifth special case, so a call site added to the
104
+ * schema later is covered by being a call.
105
+ */
106
+ const calls = [
107
+ ...(api?.run ?? []).map((c, i) => ({ at: `run[${i}]${c?.name ? ` (${c.name})` : ""}`, call: c })),
108
+ ...(api?.narrate?.request ? [{ at: "narrate.request", call: api.narrate.request }] : []),
109
+ ...Object.entries(api?.service ?? {}).flatMap(([m, spec]) =>
110
+ (spec?.calls ?? []).map((c, i) => ({ at: `service.${m}.calls[${i}]${c?.name ? ` (${c.name})` : ""}`, call: c })),
111
+ ),
112
+ ].filter((c) => c.call && typeof c.call === "object");
113
+
114
+ /**
115
+ * Every LIST of calls in the node, with whether its last one may stream.
116
+ *
117
+ * A graph run's last call is the node's answer, so it may stream. A service method
118
+ * hands back one value and has no connector to stream onto, so all of its calls settle.
119
+ * Same list shape, one rule that differs, stated here rather than inferred.
120
+ */
121
+ const chains = [
122
+ ...(api?.run ? [{ at: "run", list: api.run, lastMayStream: true }] : []),
123
+ ...Object.entries(api?.service ?? {})
124
+ .filter(([, spec]) => spec?.calls)
125
+ .map(([m, spec]) => ({ at: `service.${m}.calls`, list: spec.calls, lastMayStream: false })),
126
+ ];
127
+
128
+ // A PURE SERVICE node legitimately has no outputs: it is never triggered by the
129
+ // graph, it answers callers and hands them a value. Any other node with no outputs
130
+ // cannot be wired to anything.
131
+ //
132
+ // AN ANNOTATION node is the second legitimate case: no inputs, no outputs, no api. `Note` is
133
+ // canvas furniture — a markdown sticky explaining a workflow. Having NO INPUTS is what keeps
134
+ // it out of the data flow, since the graph can never reach it, and that is exactly why it also
135
+ // needs no outputs. Requiring one would force a dot that could never carry anything.
136
+ //
137
+ // Both halves matter: a node with inputs but no outputs is still an error, because something
138
+ // reaches it and then the data stops dead.
139
+ const isAnnotation = !outputs.size && !(iface.inputs ?? []).length && !api;
140
+ if (!outputs.size && !api?.service && !isAnnotation)
141
+ report("error", F.iface ?? F.node, `declares no outputs. A node that emits nothing cannot be wired to anything`);
142
+
143
+ // The service channel must match what the node advertises. A method with no
144
+ // connector is undiscoverable; a connector method with no implementation is a lie
145
+ // that only surfaces when a consumer calls it (08-mcp-services.md).
146
+ if (api?.service || (iface.serviceConnectors ?? []).some((s) => s.isService === true)) {
147
+ const advertised = new Set(
148
+ (iface.serviceConnectors ?? []).filter((s) => s.isService === true).flatMap((s) => s.methods ?? []),
149
+ );
150
+ /**
151
+ * `getSchema` IS TOOL DISCOVERY, not a tool, and must NOT appear in `methods`.
152
+ *
153
+ * An agent calls it by CONVENTION once per session to learn what the node offers
154
+ * (ServiceConnector aggregates the replies across every wired MCP node). Listing it
155
+ * would advertise it to the model as one more tool it could call, which is both useless
156
+ * to the model and a way to waste a turn.
157
+ *
158
+ * So it is exempt from the rule below rather than being an exception an author has to
159
+ * discover by hitting the error. Nothing else is exempt: every real method must be
160
+ * advertised or nothing can reach it.
161
+ */
162
+ const DISCOVERY_METHOD = "getSchema";
163
+ for (const method of Object.keys(api?.service ?? {}))
164
+ if (method !== DISCOVERY_METHOD && !advertised.has(method))
165
+ report("error", F.api, `api/service.yaml has "${method}" but no serviceConnector with isService: true lists it in methods. Nothing can discover it`);
166
+
167
+ /**
168
+ * A PURE SERVICE NODE MUST SAY SO, or the canvas draws the wrong card.
169
+ *
170
+ * Pure means it offers methods and has NO outputs: the graph never runs it, a consumer
171
+ * calls it. The default card draws input and output dots that can never carry anything,
172
+ * which invites someone to wire it into the data flow instead of attaching it, and the
173
+ * wire silently does nothing.
174
+ *
175
+ * Caught because SpatialSearch shipped without it and looked like an ordinary node on
176
+ * the canvas. OpenAIEmbeddingService had been missing it for longer.
177
+ */
178
+ if (!outputs.size && api?.service && node.template !== "service")
179
+ report(
180
+ "error",
181
+ F.node,
182
+ `offers service methods and declares no outputs, so it is a pure service node and needs "template: service". Without it the canvas draws connector dots that can never carry anything (DECLARATIVE_NODES.md §5)`,
183
+ );
184
+
185
+ // The mirror of it: advertising the discovery method as a tool.
186
+ if (advertised.has(DISCOVERY_METHOD))
187
+ report("error", F.iface ?? F.node, `a serviceConnector lists "${DISCOVERY_METHOD}" in methods. It is tool DISCOVERY, called by convention, and listing it advertises it to the model as a tool (08-mcp-services.md)`);
188
+ for (const method of advertised)
189
+ if (!api?.service?.[method])
190
+ report("error", F.iface ?? F.node, `advertises method "${method}" but api/service.yaml has no implementation. A consumer calling it gets an error`);
191
+ }
192
+
193
+ // ── tier 3: semantics ──
194
+
195
+ // kind is DECLARED but VERIFIED. Getting it wrong used to surface only at runtime.
196
+ // A tool exchange is a multi-turn LOOP, so it makes the node a CallbackNode just as
197
+ // surely as a streaming transport does, and it must name a connector that grants tools.
198
+ // Tools reach a node through an mcp serviceConnector it CONSUMES. Declaring a tool
199
+ // exchange without one means the request would carry an empty tools array.
200
+ if (api?.toolExchange) {
201
+ const consumes = (iface.serviceConnectors ?? []).filter((s) => s.serviceType === "mcp" && s.isService === false);
202
+ if (!consumes.length)
203
+ report(
204
+ "error",
205
+ F.iface ?? F.node,
206
+ `declares a toolExchange but no serviceConnector with serviceType: mcp and isService: false. There is nothing to source tools from`,
207
+ );
208
+ }
209
+
210
+ // The LAST step is the node's answer, so it is the only one whose transport can make
211
+ // the node stream. Every earlier step settles by definition.
212
+ if (api?.run?.length) {
213
+ const finalCall = api.run[api.run.length - 1] ?? {};
214
+ const streaming = ["sse", "ndjson", "awsEventStream", "ws"].includes(finalCall.transport);
215
+ // SPAWN implies an actor; CONTINUE does not. A CONTINUE port only says something re-fires this
216
+ // node, and a ROUTED EDGE does that to a settle-once node — which is exactly how LoopEnd
217
+ // re-triggers LoopStart, one fresh execution per pass. See compose.ts for the full reasoning;
218
+ // the two must agree or lint and load disagree about the same manifest.
219
+ const spawnPort = (iface.inputs ?? []).some((i) => i.signal === "SPAWN");
220
+ const derived = streaming || spawnPort || api.toolExchange ? "CallbackNode" : "PromiseNode";
221
+ if (node.kind && node.kind !== derived) {
222
+ const why = streaming
223
+ ? `its last step's transport "${finalCall.transport}" streams`
224
+ : api.toolExchange
225
+ ? `it declares a toolExchange, which is a multi-turn loop`
226
+ : `an input declares a SPAWN signal`;
227
+ report("error", F.node, `kind is "${node.kind}" but this node is a ${derived}: ${why} (DECLARATIVE_NODES.md §5)`);
228
+ }
229
+ derivedKinds.set(label, derived);
230
+
231
+ // The events table is compulsory for a node the graph can trigger: it is the only
232
+ // way anything leaves the node, so without it the node is a call into the void.
233
+ if (!(api.events ?? []).length)
234
+ report("error", F.api, `has run calls but no api/events.yaml. The events table is compulsory, it is the only way anything leaves a node (DECLARATIVE_NODES.md §5)`);
235
+ } else if (existsSync(join(dir, "src")) === false && !parts.api && !isAnnotation) {
236
+ // An ANNOTATION node is exempt, because "does nothing" is the whole specification. `Note`
237
+ // has no inputs, no outputs and no api: it is a markdown sticky on the canvas. Warning about
238
+ // it would train a reader to ignore this message, which is the only thing that makes it
239
+ // useful for the case it IS about — a node someone forgot to finish.
240
+ report("warn", F.node, `has no api.yaml. A manifest node with no upstream call does nothing`);
241
+ }
242
+
243
+ // ── the events table: one row per output connector, in connector order ──
244
+ //
245
+ // This is the whole DX bet. A reader should learn a node's outward behaviour from ONE
246
+ // ordered list rather than from four scattered keys, so the order is enforced, not
247
+ // merely suggested. Enforcing it is also the only way it stays true after edits.
248
+ const rows = api?.events ?? [];
249
+ const streams = ["sse", "ndjson", "awsEventStream", "ws"].includes(api?.run?.[api.run.length - 1]?.transport);
250
+ const outputOrder = (iface.outputs ?? []).map((o) => o.name);
251
+ const SOURCES = ["response", "narrator", "tool", "complete"];
252
+
253
+ for (const [i, r] of rows.entries()) {
254
+ const from = r.from ?? "response";
255
+ if (!SOURCES.includes(from))
256
+ report("error", F.api, `events[${i}] has from: "${from}". Must be one of ${SOURCES.join(", ")} (DECLARATIVE_NODES.md §5)`);
257
+ /**
258
+ * A `send` ROW ADDRESSES A NODE, NOT A CONNECTOR, so the coverage and order rules below do
259
+ * not apply to it: there is no dot for it to cover and none to be out of order with. What it
260
+ * must not do is claim both, which would leave the reader unable to say where the value went.
261
+ */
262
+ if (r.send && r.emit)
263
+ report("error", F.api, `events[${i}] sets both emit and send. A row either writes a connector or addresses a node, never both`);
264
+ if (r.send && typeof r.send === "string" && !r.send.includes("{{"))
265
+ report(
266
+ "warn",
267
+ F.api,
268
+ `events[${i}] sends to the literal id "${r.send}". A hardcoded target only works on a canvas where that id exists — read it from config instead, e.g. {{ config.loopStartNodeId }}`,
269
+ );
270
+ if (!r.send && !outputs.has(r.emit))
271
+ report("error", F.api, `events[${i}] emits to "${r.emit}", which is not a declared output (interface.yaml)`);
272
+ // `match` names a streamed event type, so it means nothing on the other sources.
273
+ if (r.match && from !== "response")
274
+ report("error", F.api, `events[${i}] (${r.emit}) has a match but from: ${from}. Only a "response" row matches an event type`);
275
+ /**
276
+ * A STREAMING ROW NORMALLY NAMES ITS EVENT, because otherwise it fires on all of them.
277
+ *
278
+ * Unless the vendor has no event types to name. OpenAI's Responses API tags every SSE event with a
279
+ * `type`; a Chat Completions chunk — GLM, Grok, most OpenAI-COMPATIBLE vendors — carries none at
280
+ * all, so there is nothing a match could compare and the rule would demand the impossible.
281
+ *
282
+ * `toolExchange.call.each` is the tell, and it is a declared fact rather than a guess: `each` exists
283
+ * precisely because that vendor streams tool calls in fragments, which is the same wire style that
284
+ * omits event types. Such a row reads a field that only some events carry, and the emitter drops an
285
+ * undefined value — so "fires on every event" is true and harmless there.
286
+ */
287
+ const untypedEvents = !!api?.toolExchange?.call?.each;
288
+ if (streams && from === "response" && !r.match && !untypedEvents)
289
+ report("error", F.api, `events[${i}] (${r.emit}) is a streaming response row with no match. It would fire on every event`);
290
+ if (r.throttleMs && r.throttleChars)
291
+ report("error", F.api, `events[${i}] (${r.emit}) sets both throttleMs and throttleChars. Pick the one that suits the output`);
292
+ if (r.accumulate && from === "tool")
293
+ report("warn", F.api, `events[${i}] (${r.emit}) accumulates a tool result into a string. Tool results are usually objects`);
294
+ }
295
+
296
+ // Coverage: an output nothing emits to is dead, and downstream nodes can wire to it.
297
+ if (api) {
298
+ const emitted = new Set(rows.filter((r) => !r.send).map((r) => r.emit));
299
+ for (const o of outputs)
300
+ if (!emitted.has(o)) report("warn", F.iface ?? F.node, `output "${o}" is declared but no events row emits to it`);
301
+ }
302
+
303
+ // Order: the rows a reader sees must be the connectors they see, in the same order.
304
+ // Compared over the outputs that ARE covered, so a missing row reports once (above)
305
+ // rather than also reading as a reorder.
306
+ if (rows.length) {
307
+ const rowOrder = [...new Set(rows.filter((r) => !r.send).map((r) => r.emit))].filter((n) => outputs.has(n));
308
+ const want = outputOrder.filter((n) => rowOrder.includes(n));
309
+ if (rowOrder.join(",") !== want.join(","))
310
+ report(
311
+ "error",
312
+ F.api,
313
+ `events rows are ordered ${rowOrder.join(", ")} but the output connectors are ${want.join(", ")}. Keep the table in connector order so the node reads top to bottom (DECLARATIVE_NODES.md §5)`,
314
+ );
315
+ }
316
+
317
+ const fixture = parts.test?.doc?.testData?.config ?? {};
318
+ const known = Object.keys(config?.configSchema?.properties ?? {});
319
+ if (config) for (const k of Object.keys(fixture))
320
+ if (!known.includes(k))
321
+ report("error", F.test, `testData.config sets "${k}", which config.yaml does not declare. The value is silently ignored, so the fixture drifts from the node`);
322
+
323
+ // Retired keys. Left in place they would silently do nothing, which is worse than a
324
+ // hard error, so each one names its replacement.
325
+ if (api?.response !== undefined)
326
+ report("error", F.api, `api.response is retired: transport/terminator/error moved INTO the call (request), and what leaves the node is the events table (DECLARATIVE_NODES.md §5)`);
327
+ if (api?.narrate?.output !== undefined)
328
+ report("error", F.api, `narrate.output is retired. The narrator's line lands via an \`events\` row with from: narrator (DECLARATIVE_NODES.md §5)`);
329
+ for (const [name, m] of Object.entries(api?.service ?? {}))
330
+ if (m.response !== undefined)
331
+ report("error", F.api, `service.${name}.response is retired: transport/error moved into the call, and the value it hands back is service.${name}.returns (DECLARATIVE_NODES.md §5)`);
332
+
333
+ // ── chained calls ──
334
+ //
335
+ // A chain is reached by NAME, so the names are the contract: a step with none is
336
+ // unreachable, and two calls sharing one silently shadow each other. Both would fail
337
+ // as a confusing empty value inside somebody's `returns` expression rather than here.
338
+ for (const { at, list, lastMayStream } of chains) {
339
+ const names = [];
340
+ for (const [i, s] of list.entries()) {
341
+ const isLast = i === list.length - 1;
342
+ if (!s?.name) {
343
+ report("error", F.api, `${at}[${i}] has no name. A call is reached as calls.<name>, so an unnamed one cannot be read by a later call, an events row, or returns (DECLARATIVE_NODES.md §5)`);
344
+ } else {
345
+ if (names.includes(s.name))
346
+ report("error", F.api, `${at} declares two calls named "${s.name}". The second silently overwrites the first in calls.<name>`);
347
+ names.push(s.name);
348
+ }
349
+ // `auth` on a CALL is retired (2026-07-28). It now means the opposite thing one file
350
+ // away — node.yaml's `auth` is inbound, about who may RUN the node — and the collision
351
+ // was a reliable source of confusion. The outbound one took the name of what it holds.
352
+ //
353
+ // An ERROR rather than a silent accept: the executor reads `credential` only, so a call
354
+ // left on the old spelling would send NO credential at all and fail as a vendor 401,
355
+ // which reads as a bad key rather than a manifest that was never migrated.
356
+ if (s?.auth !== undefined)
357
+ report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) uses "auth", which is retired on a call. Rename it to "credential" — that is how this node proves itself to the VENDOR. node.yaml's "auth" is the other direction: who may run this node (DECLARATIVE_NODES.md §9.13)`);
358
+
359
+ // Only the LAST step may stream, and only on the workflow channel: its reply is the
360
+ // node's answer. An earlier step exists so a later one can READ it, and there is
361
+ // nothing to read from a stream nobody framed.
362
+ // A STATE call reaches Redis, not a host, so the request rules do not apply to it
363
+ // and the request keys are meaningless on it.
364
+ if (s?.state) {
365
+ for (const k of ["method", "url", "transport", "credential", "body", "retry", "headers", "query"])
366
+ if (s[k] !== undefined)
367
+ report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) is a "${s.state}" state call, so "${k}" does nothing. A state call reaches platform storage, not a host`);
368
+ const writes = s.state === "merge" || s.state === "save";
369
+ if (!writes && s.value !== undefined)
370
+ report("error", F.api, `${at}[${i}] (${s.name}) sets "value", which only a "merge" or a "save" writes`);
371
+ if (s.state !== "drain" && s.max !== undefined)
372
+ report("error", F.api, `${at}[${i}] (${s.name}) sets "max", which only a "drain" bounds`);
373
+ if (writes && s.value === undefined)
374
+ report("error", F.api, `${at}[${i}] (${s.name}) is a "${s.state}" with no "value", so it would store nothing`);
375
+ // A `save` writes the RUN's saved-context hash, whose key is the engine's — `saved:<executionId>`,
376
+ // read by the template resolver and deleted when the run ends. A manifest naming a key here
377
+ // would be guessing at another component's layout, and the deployment namespace would be
378
+ // applied to the one key that must not carry it, so the write would land where nothing reads.
379
+ // Silent, and the toggle would still look like it worked.
380
+ if (s.state === "save" && s.key !== undefined)
381
+ report("error", F.api, `${at}[${i}] (${s.name}) is a "save", so "key" does nothing — the run's saved-context key belongs to the engine and the executor builds it`);
382
+ if (s.state !== "save" && s.key === undefined)
383
+ report("error", F.api, `${at}[${i}] (${s.name}) is a "${s.state}" with no "key", so there is nothing to address`);
384
+ continue;
385
+ }
386
+
387
+ // A POLLED call's STATUS check is a GET unless it says otherwise, so a body on it would be
388
+ // built and silently dropped — the same shape of bug as `timeoutMs` being declared and never
389
+ // read. Starting the job is unaffected: that already uses the call's own method and body.
390
+ if (s?.poll?.body && s.poll.method !== "POST")
391
+ report("error", F.api, `${at}[${i}] (${s.name}) sets poll.body, which is only sent when poll.method is POST. As a GET it would be discarded`);
392
+
393
+ // A LOOP entry keeps an index rather than reaching a host, so the request keys are as
394
+ // meaningless on it as they are on a state call.
395
+ if (s?.loop) {
396
+ for (const k of ["method", "url", "transport", "credential", "body", "retry", "headers", "query"])
397
+ if (s[k] !== undefined)
398
+ report("error", F.api, `${at}[${i}] (${s.name ?? "unnamed"}) is a "${s.loop}" loop call, so "${k}" does nothing. A loop call keeps an index in platform storage, not a host`);
399
+ // `key` is the LoopStart node's id: `{{ scope.nodeId }}` from LoopStart itself, or the paired
400
+ // id from LoopEnd's config. Without it there is no loop to address, and the executor cannot
401
+ // guess which of two loops on one canvas was meant.
402
+ if (s.key === undefined)
403
+ report("error", F.api, `${at}[${i}] (${s.name}) is a "${s.loop}" with no "key". A loop is addressed by its LoopStart node id`);
404
+ // `open` needs the array; `advance` may collect a value; `read` moves nothing and stores
405
+ // nothing, so a value on it would be silently discarded.
406
+ if (s.loop === "open" && s.value === undefined)
407
+ report("error", F.api, `${at}[${i}] (${s.name}) is a loop "open" with no "value", so it would iterate an empty list`);
408
+ if (s.loop === "read" && s.value !== undefined)
409
+ report("error", F.api, `${at}[${i}] (${s.name}) is a loop "read", which stores nothing, so "value" is discarded`);
410
+ if (s.max !== undefined)
411
+ report("error", F.api, `${at}[${i}] (${s.name}) sets "max", which only a "drain" bounds`);
412
+ continue;
413
+ }
414
+
415
+ const settles = s?.transport === "json" || s?.transport === "text" || s?.transport === "xml" || s?.transport === "headers" || s?.transport === "binary";
416
+ if (s?.transport && !settles && !(isLast && lastMayStream))
417
+ report(
418
+ "error",
419
+ F.api,
420
+ isLast
421
+ ? `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}". A provided method hands back ONE value, so it has no connector to stream onto (DECLARATIVE_NODES.md §5)`
422
+ : `${at}[${i}] (${s.name ?? "unnamed"}) has transport "${s.transport}" but is not the last step. Only the last may stream (DECLARATIVE_NODES.md §5)`,
423
+ );
424
+ // Only when it tests an earlier REPLY. A first call may perfectly well be gated on
425
+ // config or on the caller's params, and Salesforce's is. What is meaningless is
426
+ // reading calls.<name> before any call has happened. Warning on any `when` at all
427
+ // made a correct manifest look wrong, which is how a warning stops being read.
428
+ if (i === 0 && s?.when && /\bcalls\./.test(String(s.when)))
429
+ report("warn", F.api, `${at}[0] (${s.name ?? "unnamed"}) reads calls.* but is the FIRST call, so there is no earlier reply and the test always fails`);
430
+ }
431
+
432
+ // A step naming one that does not exist reads as empty, and the request goes out
433
+ // with a hole in it rather than failing.
434
+ for (const [i, s] of list.entries())
435
+ for (const [, ref] of JSON.stringify(s ?? {}).matchAll(/\bcalls\.([A-Za-z0-9_]+)/g)) {
436
+ const earlier = names.slice(0, i);
437
+ if (!earlier.includes(ref))
438
+ report(
439
+ "error",
440
+ F.api,
441
+ names.includes(ref)
442
+ ? `${at}[${i}] (${s.name}) reads calls.${ref}, which happens LATER. A call sees only the calls before it`
443
+ : `${at}[${i}] (${s.name}) reads calls.${ref}, which is not a call in this list`,
444
+ );
445
+ }
446
+ }
447
+
448
+ // The narrator's call is not part of a list: nothing reads its reply back and it can
449
+ // never be followed by another, so a name or a when on it does nothing at all.
450
+ for (const { at, call } of calls)
451
+ if (at === "narrate.request")
452
+ for (const k of ["name", "when"])
453
+ if (call[k] !== undefined)
454
+ report("error", F.api, `${at} sets "${k}", which belongs to a call in a list and is ignored here (DECLARATIVE_NODES.md §5)`);
455
+
456
+ // RETIRED SHAPES. Each would silently do nothing rather than fail: the executor reads
457
+ // `run` and `service`, finds no calls at all, and the node dies on its first run with a
458
+ // message about neither. Naming the replacement matters more than the error itself,
459
+ // because these are what every older example and every half-remembered habit produces.
460
+ for (const [old, now] of [["request", "api/run.yaml, a LIST"], ["steps", "api/run.yaml"], ["provides", "api/service.yaml"]])
461
+ if (api?.[old] !== undefined)
462
+ report("error", F.api, `api/${old}.yaml is retired. This is ${now}. A node's calls are ALWAYS a list, each entry starting "- name: <what it fetches>" (DECLARATIVE_NODES.md §5)`);
463
+
464
+ for (const [name, m] of Object.entries(api?.service ?? {}))
465
+ for (const old of ["request", "steps"])
466
+ if (m[old] !== undefined)
467
+ report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (DECLARATIVE_NODES.md §5)`);
468
+
469
+ // Credentials must resolve to a declared type, or the run fails at execute time.
470
+ for (const c of iface.credentials ?? []) {
471
+ const name = c.name;
472
+ if (!pkg.credentials.has(name))
473
+ report("error", F.iface ?? F.node, `needs credential "${name}" but no credentials/${name}.yaml exists in this package. A package DECLARES the credentials it needs (04-credentials.md)`);
474
+ }
475
+
476
+ // configSchema internal consistency.
477
+ if (config?.configSchema) {
478
+ const props = config.configSchema.properties ?? {};
479
+ const names = Object.keys(props);
480
+
481
+ /**
482
+ * THE RUN-AUTHORIZATION FIELDS ARE RESERVED (UNOVERSE_NODE_ACCESS_CHROME.md,
483
+ * decided 2026-08-21). They are platform CHROME: compose.ts injects the canonical
484
+ * pair into every runnable node's composed schema, so every settings panel carries
485
+ * them and no author writes them.
486
+ *
487
+ * This rule used to be the inverse — every config had to declare both, and lint
488
+ * held all 76 copies byte-identical, which is the signature of a thing the platform
489
+ * should own. A manifest declaring them now is the old pattern: at compose time the
490
+ * canonical definition supersedes it anyway, so the copy can only mislead whoever
491
+ * reads the file.
492
+ */
493
+ for (const field of ["authRequired", "authRole"]) {
494
+ if (props[field])
495
+ report(
496
+ "error",
497
+ F.config,
498
+ `config.yaml declares "${field}", which is platform chrome: compose injects the run-authorization controls into every node's settings form, and a manifest must not carry its own copy (15-who-can-run-it.md). Delete the field and its ui:order entry`,
499
+ );
500
+ }
501
+ for (const r of config.configSchema.required ?? [])
502
+ if (!names.includes(r)) report("error", F.config, `required names "${r}", which is not a property`);
503
+
504
+ const order = config["ui:order"] ?? [];
505
+ for (const f of order) if (!names.includes(f)) report("error", F.config, `ui:order names "${f}", which is not a property`);
506
+ if (order.length) for (const p of names) if (!order.includes(p)) report("warn", F.config, `property "${p}" is missing from ui:order. It renders last, in an arbitrary place`);
507
+
508
+ for (const [n, f] of Object.entries(props)) {
509
+ if (f.enum && f.enumNames && f.enum.length !== f.enumNames.length)
510
+ report("error", F.config, `${n}: enum has ${f.enum.length} values but enumNames has ${f.enumNames.length}. They are positionally parallel`);
511
+ if (f.enum && f.default !== undefined && !f.enum.includes(f.default))
512
+ report("error", F.config, `${n}: default "${f.default}" is not one of its enum values`);
513
+ for (const dep of Object.keys(f["ui:dependencies"] ?? {}))
514
+ if (!names.includes(dep)) report("error", F.config, `${n}: ui:dependencies names "${dep}", which is not a sibling property`);
515
+ // A template field on an object/array takes a `return` expression, not handlebars.
516
+ if (f["ui:field"] === "template" && (f.type === "object" || f.type === "array") && typeof f.default === "string" && f.default.trim() && !f.default.startsWith("return "))
517
+ report("warn", F.config, `${n}: is an object template, so its value is a "return ..." expression, not handlebars (06-config-schema.md)`);
518
+ // A NUMBER OR A BOOLEAN IS NEVER A TEMPLATE FIELD, and this is an error rather than a
519
+ // warning because it takes the browser down. `ui:field: template` draws the CodeMirror
520
+ // editor, whose document is TEXT; handed the number 10 or the boolean true it builds a
521
+ // change set of length NaN, empties itself and freezes the tab the moment the config
522
+ // panel opens (SpatialSearch, 2026-08-13 to 2026-08-15).
523
+ //
524
+ // Nothing is lost by leaving it off. The marker is not what makes a field wirable: the
525
+ // engine resolves {{ }} in ANY string config value whatever the field is marked as
526
+ // (TemplateResolver.ts), so a route still decides a stepper's or a tick-box's value per
527
+ // run. The marker only chooses the control that is drawn, and the control follows type.
528
+ if (f["ui:field"] === "template" && (f.type === "number" || f.type === "integer" || f.type === "boolean"))
529
+ report(
530
+ "error",
531
+ F.config,
532
+ `${n}: is a ${f.type}, so it cannot carry "ui:field: template" — that draws a text editor, which a non-string value crashes. Drop the marker; a route can still wire the field, since the engine templates any string config value (06-config-schema.md)`,
533
+ );
534
+ }
535
+
536
+ // Every {{ config.x }} in the request must name a real config field.
537
+ if (api)
538
+ for (const ref of [...JSON.stringify(api).matchAll(/\{\{[~\s]*(?:[#\/]?\w+\s+)?(?:\(\w+\s+)?config\.([A-Za-z0-9_]+)/g)].map((m) => m[1]))
539
+ if (!names.includes(ref)) report("error", F.api, `templates {{ config.${ref} }} but "${ref}" is not a config property`);
540
+ }
541
+
542
+ // A template naming an unregistered helper compiles and then throws at run time.
543
+ // The registered set lives in engine/src/template/StringTemplateResolver.ts.
544
+ //
545
+ // Scan ONLY inside {{ }}. The rest of an api.yaml holds `return ...` expressions
546
+ // whose arrow callbacks — filter(e => ...) — read exactly like a subexpression.
547
+ if (api) {
548
+ const seen = new Set();
549
+ for (const [, body] of JSON.stringify(api).matchAll(/\{\{([^}]*)\}\}/g)) {
550
+ const helpers = [
551
+ ...[...body.matchAll(/^[~\s]*[#\/]([a-zA-Z][\w]*)/g)].map((m) => m[1]), // {{#if}} {{/if}}
552
+ ...[...body.matchAll(/\(([a-zA-Z][\w]*)\s/g)].map((m) => m[1]), // (eq a b)
553
+ ];
554
+ for (const h of helpers)
555
+ if (!HANDLEBARS_HELPERS.has(h) && !seen.has(h)) {
556
+ seen.add(h);
557
+ report("error", F.api, `template uses helper "${h}", which is not registered (engine/src/template/StringTemplateResolver.ts). It throws at run time, not at build time`);
558
+ }
559
+ // {{prompt.x}} / {{blocks.x}} must name a real block, else it resolves to
560
+ // EMPTY and the instruction just silently vanishes from the prompt.
561
+ for (const [, b] of body.matchAll(/\b(?:prompt|blocks)\.([A-Za-z0-9_]+)/g))
562
+ if (!promptBlocks().has(b) && !seen.has(b)) {
563
+ seen.add(b);
564
+ report("error", F.api, `references prompt block "{{prompt.${b}}}", which does not exist in prompts/blocks/. It resolves to empty and the instruction silently disappears`);
565
+ }
566
+ }
567
+ }
568
+
569
+ // A node nobody can run is a node nobody can trust.
570
+ //
571
+ // Unless there is nothing to run. A node with NO `api` makes no call and fires no events —
572
+ // `Note` is the case, a markdown sticky on the canvas — so a fixture could only assert that
573
+ // nothing happened. Demanding one would mean inventing sample data for a node that never
574
+ // executes, which teaches a reader that fixtures are paperwork rather than proof.
575
+ //
576
+ // Scoped to the absence of `api` ALONE. An events-only node (no calls, but an events table,
577
+ // like IfElse) DOES run and still needs its fixture.
578
+ if (!test?.testData && !api) {
579
+ // nothing to run, so nothing to fixture
580
+ } else if (!test?.testData) {
581
+ report("error", F.node, `has no testData. Every node needs sample data so it can be loaded and run (DECLARATIVE_NODES.md §6)`);
582
+ } else {
583
+ if (config?.configSchema) {
584
+ /**
585
+ * A TEMPLATE FIELD'S DECLARED TYPE DESCRIBES THE FORM, NOT THE RESOLVED VALUE.
586
+ *
587
+ * `ui:field: template` on an `object` means the author writes a `return` expression, and
588
+ * the ENGINE resolves it before the node runs. So the type in configSchema is about
589
+ * authoring, while a fixture holds what actually arrives — and those legitimately differ.
590
+ * IfElse is the clean case: `condition` is an object-typed expression field whose resolved
591
+ * value is a boolean, which is the entire point of the node.
592
+ *
593
+ * So the type constraint is dropped for template fields only. Everything else about them is
594
+ * still checked (required, unknown keys), and every non-template field is still checked in
595
+ * full, which is what catches a fixture that has drifted from the form.
596
+ */
597
+ const relaxed = JSON.parse(JSON.stringify(config.configSchema));
598
+ for (const prop of Object.values(relaxed.properties ?? {})) {
599
+ if (prop && typeof prop === "object" && prop["ui:field"] === "template") delete prop.type;
600
+ }
601
+ const v = new Validator(relaxed, "7", false);
602
+ const r = v.validate(test.testData.config ?? {});
603
+ for (const e of r.errors.filter((e) => e.instanceLocation !== "#").slice(0, 4))
604
+ report("error", F.test, `testData.config${e.instanceLocation.replace(/^#/, "")} ${e.error}. The sample must satisfy this node's own configSchema`);
605
+ }
606
+ for (const k of Object.keys(test.testData.inputs ?? {}))
607
+ if (!inputs.has(k)) report("error", F.test, `testData.inputs has "${k}", which is not a declared input`);
608
+ // On the workflow channel an expect key names an output connector. On the SERVICE
609
+ // channel there are no connectors: the method hands back one value, so the keys are
610
+ // just labels for the assertions over it.
611
+ const call = test.testData.call;
612
+ if (!call)
613
+ for (const k of Object.keys(test.testData.expect ?? {}))
614
+ if (!outputs.has(k)) report("error", F.test, `testData.expect has "${k}", which is not a declared output`);
615
+
616
+ if (call && !api?.service?.[call.method])
617
+ report("error", F.test, `testData.call names method "${call.method}", which api.yaml does not provide`);
618
+ if (!call && api?.service && !api?.run)
619
+ report("error", F.test, `this node has only service methods, so testData needs a "call" block naming one. Otherwise nothing can run it`);
620
+ }
621
+
622
+ // WHO MAY RUN THIS NODE (DECLARATIVE_NODES.md §9.13).
623
+ //
624
+ // COMPULSORY, and that is the entire point of the block. Before 2026-07-28 authorization was
625
+ // an OPTIONAL `requires: { role }`, so a node that said nothing was indistinguishable from a
626
+ // node nobody had thought about — and every one of the 64 said nothing. Silence is not an
627
+ // answer to "who may run this", so each node now states its stance out loud and a reviewer
628
+ // reads a decision rather than an absence.
629
+ if (node.requires !== undefined)
630
+ report("error", F.node, `"requires" is retired on a node. Authorization is now "auth: { required, role }" — and unlike requires it is compulsory, so a node cannot stay silent about who may run it (DECLARATIVE_NODES.md §9.13)`);
631
+
632
+ const auth = node.auth;
633
+ if (auth === undefined) {
634
+ report("error", F.node, `node.yaml has no "auth" block. Every node states who may run it: "auth: { required: false }" adds no requirement of its own (the trigger still owns the door), "required: true" demands a signed-in caller, and "role: noun:verb" demands a specific claim (DECLARATIVE_NODES.md §9.13)`);
635
+ } else if (typeof auth.required !== "boolean") {
636
+ report("error", F.node, `auth.required must be true or false, written out. Leaving it off would put the node back to being silent about authorization, which is what this block exists to stop (DECLARATIVE_NODES.md §9.13)`);
637
+ } else if (auth.role !== undefined && auth.required === false) {
638
+ // A role lives on a token, so demanding one while waiving the token is unsatisfiable. It
639
+ // would read as "protected" on the acceptance screen and admit everyone at run time.
640
+ report("error", F.node, `auth declares role "${auth.role}" with required: false. A role lives on a token, so this can never be satisfied — a node asking for a role is asking for a signed-in caller (DECLARATIVE_NODES.md §9.13)`);
641
+ }
642
+
643
+ // whenToUse decides whether the node SURFACES to the building agent at all.
644
+ const w = (node.whenToUse ?? "").trim();
645
+ if (!w) {
646
+ // Absorbed from scripts/check-node-meta.mjs (2026-07-29, previously an orphan
647
+ // nothing invoked): a node without whenToUse is invisible to catalog ranking
648
+ // and marketplace search — a silent discovery failure, so it fails the lint.
649
+ report("error", F.node, `whenToUse is missing. The ranker embeds it for catalog/semantic search; without it this node cannot be discovered by capability (14-node-discoverability.md)`);
650
+ }
651
+ if (w) {
652
+ if (/^use this (node|when)/i.test(w))
653
+ report("warn", F.node, `whenToUse opens by restating itself. Lead with the OUTCOME in task vocabulary (14-node-discoverability.md)`);
654
+ if (/^(hybrid|attach|this is an?|a powerful)/i.test(w))
655
+ report("warn", F.node, `whenToUse opens with plumbing or marketing. Outcome first, mechanism last; it sinks the ranking (14-node-discoverability.md)`);
656
+ if (node.description && w.toLowerCase() === node.description.toLowerCase())
657
+ report("error", F.node, `whenToUse merely repeats description. It carries zero selection signal (14-node-discoverability.md)`);
658
+ }
659
+
660
+ // ALLOWED_HOSTS. A manifest cannot execute, but it can say "send this credential to
661
+ // evil.example". SECURITY.md bounds a code node by provenance and a template
662
+ // expression by having no credentials in scope; a manifest node is neither, so the
663
+ // declared host list is its boundary. Deny by default, and catch it statically here
664
+ // as well as at run time.
665
+ // Per CALL, not per node. This used to read `api.request` alone, so a host named only
666
+ // in narrate.request or inside a service method was never checked against the allowedHosts
667
+ // list at all. For a format whose premise is that a manifest may arrive as data, a
668
+ // boundary that skips four of the five places a URL can appear is not a boundary.
669
+ for (const { at, call } of calls) {
670
+ if (!call.url) continue;
671
+ // An EXPRESSION url cannot be parsed statically: there is no literal to blank down to
672
+ // a host. Runtime still enforces allowedHosts on the RESOLVED url, through the same single
673
+ // chokepoint every call passes, so this loses a build-time warning and not the
674
+ // control. Said out loud rather than skipped, because a check that quietly covers less
675
+ // than it claims is exactly what this file got wrong once already today.
676
+ if (String(call.url).trimStart().startsWith("return ")) {
677
+ report(
678
+ "hint",
679
+ F.api,
680
+ `${at}.url is an expression, so its host cannot be checked at build time: allowedHosts is enforced at run time only for this call`,
681
+ );
682
+ continue;
683
+ }
684
+ const literal = String(call.url).replace(/\{\{[^}]*\}\}/g, "\u0000");
685
+ let host = null;
686
+ try {
687
+ host = new URL(literal.replace(/\u0000/g, "x")).host.toLowerCase();
688
+ } catch {
689
+ // A url that is ENTIRELY a template has no scheme to parse, and that is legitimate
690
+ // when the whole address comes from config — an attachment a person supplies. Only a
691
+ // package that declared "*" may do it, and there the run-time check is the real one.
692
+ report(
693
+ (pkg.allowedHosts ?? []).includes("*") ? "hint" : "error",
694
+ F.api,
695
+ `${at}.url is not a valid URL: ${call.url}` +
696
+ ((pkg.allowedHosts ?? []).includes("*")
697
+ ? " — fully templated, and this package allows any host for unauthenticated calls, so it is checked at run time"
698
+ : ""),
699
+ );
700
+ }
701
+ if (host) {
702
+ if (!/^https:/i.test(literal))
703
+ report("error", F.api, `${at}.url is not https. A credential must not travel in clear text`);
704
+ const allowedHosts = pkg.allowedHosts ?? [];
705
+ const templated = literal.includes("\u0000");
706
+ // MUST MATCH runtime/allowedHosts.ts exactly. Two matchers that disagree is worse
707
+ // than one: lint passes and the call is refused at run time, or the reverse.
708
+ // **.a.com any depth (AWS: dynamodb.us-east-1.amazonaws.com is two labels deep)
709
+ // *.a.com one level
710
+ // `*` = any host, for unauthenticated calls only. Runtime enforces the credential
711
+ // half; lint cannot see auth reliably here, so it accepts the host and leaves the
712
+ // real check to assertAllowedHost, which is the single chokepoint anyway.
713
+ const ok = allowedHosts.includes("*") || allowedHosts.some((p) =>
714
+ p.startsWith("**.")
715
+ ? host.endsWith(p.slice(2)) && host.length > p.length - 2
716
+ : p.startsWith("*.")
717
+ ? host.endsWith(p.slice(1)) && !host.slice(0, -(p.length - 1)).includes(".")
718
+ : host === p,
719
+ );
720
+ if (!allowedHosts.length)
721
+ report("error", rel(pkg.file), `node ${node.type} makes a request (${at}) but package.yaml declares no allowedHosts. A package with no declared hosts cannot call out at all`);
722
+ else if (!ok && !templated)
723
+ report("error", rel(pkg.file), `node ${node.type} calls "${host}" from ${at} but package.yaml allowedHosts is ${allowedHosts.map((e) => `"${e}"`).join(", ")}. Declare it or the executor will refuse the request`);
724
+ }
725
+ }
726
+
727
+ // The package must promise every executor capability its nodes name, or deploy
728
+ // to an older executor lints clean and fails at run time.
729
+ //
730
+ // Per call for the same reason as allowedHosts. The transport check also read
731
+ // `api.response.transport`, a key retired when a call absorbed its own framing, so it
732
+ // had been comparing undefined and passing everything ever since.
733
+ // Renamed with the call key it mirrors, so the package's promise and the call's spelling
734
+ // stay the same word. Left as `auth` it would list schemes for a key no call has.
735
+ if (pkg.requires?.auth !== undefined)
736
+ report("error", rel(pkg.file), `package.yaml uses requires.auth, which is retired. Rename it to requires.credential to match the call key (DECLARATIVE_NODES.md §9.13)`);
737
+
738
+ if (pkg.requires && api)
739
+ for (const { at, call } of calls) {
740
+ const scheme = call.credential?.scheme;
741
+ if (scheme && scheme !== "none" && !(pkg.requires.credential ?? []).includes(scheme))
742
+ report("error", rel(pkg.file), `node ${node.type} uses credential "${scheme}" at ${at} but package.yaml requires.credential does not list it`);
743
+ const t = call.transport;
744
+ if (t && !(pkg.requires.transport ?? []).includes(t))
745
+ report("error", rel(pkg.file), `node ${node.type} uses transport "${t}" at ${at} but package.yaml requires.transport does not list it`);
746
+ // A state op on an executor without it no-ops SILENTLY: the cache reads cold forever
747
+ // and the queue never drains. That is precisely what requires exists to catch.
748
+ if (call.state && !(pkg.requires.state ?? []).includes(call.state))
749
+ report("error", rel(pkg.file), `node ${node.type} uses state "${call.state}" at ${at} but package.yaml requires.state does not list it`);
750
+ // Same reason again: a paginated call on an executor without pagination silently
751
+ // returns page one, and a chunked write goes out whole and is rejected.
752
+ if (call.paginate?.strategy && !(pkg.requires.paginate ?? []).includes(call.paginate.strategy))
753
+ report("error", rel(pkg.file), `node ${node.type} paginates by "${call.paginate.strategy}" at ${at} but package.yaml requires.paginate does not list it`);
754
+ if (call.chunk && pkg.requires.chunk !== true)
755
+ report("error", rel(pkg.file), `node ${node.type} writes in batches at ${at} but package.yaml requires.chunk is not true`);
756
+ // And a POLLED call on an executor without polling is the worst of the set: the start
757
+ // reply is a RECEIPT, so the node would settle on a job id and emit it as the answer.
758
+ // Nothing errors, and every downstream field reads empty.
759
+ if (call.poll && pkg.requires.poll !== true)
760
+ report("error", rel(pkg.file), `node ${node.type} waits on a job at ${at} but package.yaml requires.poll is not true`);
761
+ }
762
+
763
+ return node;
764
+ }