@unoverse-platform/studio 0.1.96 → 0.1.98

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 (118) hide show
  1. package/bin/studio.mjs +3 -3
  2. package/local/authoring.ts +1 -1
  3. package/local/catalog.ts +22 -2
  4. package/local/keys.ts +1 -1
  5. package/local/nodes.ts +29 -42
  6. package/local/plugin.ts +75 -13
  7. package/local/sample-identity/brand.yaml +24 -0
  8. package/local/sample-identity/organisation.yaml +14 -0
  9. package/local/sample-identity/purpose.yaml +14 -0
  10. package/local/sample-identity/story.yaml +18 -0
  11. package/local/scaffold.mjs +51 -22
  12. package/local/source.ts +4 -9
  13. package/package.json +1 -1
  14. package/screens/AgentsView.tsx +173 -0
  15. package/screens/ComponentsView.tsx +2 -2
  16. package/screens/EmptyLibrary.tsx +1 -1
  17. package/screens/IdentityHeader.tsx +1 -1
  18. package/screens/IdentityView.tsx +206 -0
  19. package/screens/PromptBlocksView.tsx +1 -1
  20. package/screens/SourceView.tsx +1 -1
  21. package/screens/TemplatesLibraryView.tsx +91 -6
  22. package/screens/TemplatesView.tsx +24 -9
  23. package/screens/index.ts +5 -2
  24. package/src/App.tsx +14 -4
  25. package/src/NodeKeys.tsx +1 -1
  26. package/src/host.ts +3 -16
  27. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  28. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  29. package/vendor/base/src/agent-mcp/index.ts +5 -5
  30. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  31. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  32. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  33. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  34. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  35. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  36. package/vendor/base/src/agent-mcp/types.ts +47 -10
  37. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  38. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  39. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  40. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  41. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  42. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  43. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  44. package/vendor/base/src/definitions/load/load.ts +37 -28
  45. package/vendor/base/src/definitions/model.ts +8 -12
  46. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  47. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  48. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  49. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  50. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  51. package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
  52. package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
  53. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  54. package/vendor/base/src/definitions/theme.ts +1 -2
  55. package/vendor/base/src/items/collect.ts +33 -25
  56. package/vendor/base/src/items/publish.ts +31 -7
  57. package/vendor/base/src/lint/context.mjs +17 -0
  58. package/vendor/base/src/lint/design/file.mjs +199 -2
  59. package/vendor/base/src/lint/design/index.mjs +66 -73
  60. package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
  61. package/vendor/base/src/lint/design/walk.mjs +14 -4
  62. package/vendor/base/src/lint/index.mjs +15 -9
  63. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  64. package/vendor/base/src/lint/nodes/index.mjs +6 -1
  65. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  66. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  67. package/vendor/base/src/lint/objects/index.mjs +324 -0
  68. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  69. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  70. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  71. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  72. package/vendor/base/src/manifests/compose.ts +81 -10
  73. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  74. package/vendor/base/src/manifests/executor/context.ts +12 -1
  75. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  76. package/vendor/base/src/manifests/executor/session.ts +50 -0
  77. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  78. package/vendor/base/src/manifests/index.ts +1 -1
  79. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  80. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  81. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  82. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  83. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  86. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  87. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  88. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  89. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  90. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  91. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  92. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  93. package/vendor/base/src/manifests/source.ts +1 -1
  94. package/vendor/base/src/paths.ts +16 -8
  95. package/vendor/base/src/platform/audioManager.ts +32 -0
  96. package/vendor/base/src/platform/executionContext.ts +14 -0
  97. package/vendor/base/src/platform/liveInput.ts +52 -0
  98. package/vendor/base/src/plugins/README.md +1 -1
  99. package/vendor/base/src/plugins/discovery.ts +1 -1
  100. package/vendor/base/src/template/SafeExpression.ts +6 -0
  101. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  102. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  103. package/vendor/sdk/appEngine.tsx +31 -12
  104. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  105. package/vendor/sdk/lib/app.tsx +70 -95
  106. package/vendor/sdk/lib/connection.tsx +47 -57
  107. package/vendor/sdk/lib/core/actions.ts +6 -0
  108. package/vendor/sdk/lib/core/apps.ts +66 -81
  109. package/vendor/sdk/lib/core/client.ts +5 -14
  110. package/vendor/sdk/lib/core/connection.ts +30 -0
  111. package/vendor/sdk/lib/core/index.ts +2 -2
  112. package/vendor/sdk/lib/core/store.ts +90 -15
  113. package/vendor/sdk/lib/core/types.ts +9 -8
  114. package/vendor/sdk/lib/primitives.tsx +113 -4
  115. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  116. package/vendor/sdk/lib/render.tsx +3 -3
  117. package/vendor/sdk/lib/voice.tsx +27 -13
  118. package/vite.config.ts +1 -1
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
14
14
  import { join, relative, resolve, dirname } from "node:path";
15
- import { report, rel, refCounts, derivedKinds, allCredentials, state } from "../context.mjs";
15
+ import { report, rel, refCounts, derivedKinds, allCredentials, attachments, configFields, state } from "../context.mjs";
16
16
  import { SCHEMA_ID, SECTIONS, validateAgainst, Validator } from "./schema.mjs";
17
17
  import { readYaml, resolveRefs, sharedDirFor } from "./load.mjs";
18
18
  import { HANDLEBARS_HELPERS, promptBlocks } from "./blocks.mjs";
@@ -110,6 +110,11 @@ export function lintNode(dir, pkg) {
110
110
  (spec?.calls ?? []).map((c, i) => ({ at: `service.${m}.calls[${i}]${c?.name ? ` (${c.name})` : ""}`, call: c })),
111
111
  ),
112
112
  ].filter((c) => c.call && typeof c.call === "object");
113
+ // THE CALLS INSIDE A TURN are calls like any other: their hosts, credentials and transports
114
+ // are checked the same way, or a repeat would be a way round every rule below.
115
+ for (const { at, call } of [...calls])
116
+ for (const [j, ic] of (Array.isArray(call?.repeat?.calls) ? call.repeat.calls : []).entries())
117
+ if (ic && typeof ic === "object") calls.push({ at: `${at}.repeat.calls[${j}]${ic.name ? ` (${ic.name})` : ""}`, call: ic });
113
118
 
114
119
  /**
115
120
  * Every LIST of calls in the node, with whether its last one may stream.
@@ -124,6 +129,11 @@ export function lintNode(dir, pkg) {
124
129
  .filter(([, spec]) => spec?.calls)
125
130
  .map(([m, spec]) => ({ at: `service.${m}.calls`, list: spec.calls, lastMayStream: false })),
126
131
  ];
132
+ // A TURN is a list of calls too: named, unique, and every one settles, because the next call,
133
+ // `until` and the next turn all read it.
134
+ for (const { at, list } of [...chains])
135
+ for (const [i, s] of (Array.isArray(list) ? list : []).entries())
136
+ if (Array.isArray(s?.repeat?.calls)) chains.push({ at: `${at}[${i}].repeat.calls`, list: s.repeat.calls, lastMayStream: false });
127
137
 
128
138
  // A PURE SERVICE node legitimately has no outputs: it is never triggered by the
129
139
  // graph, it answers callers and hands them a value. Any other node with no outputs
@@ -137,7 +147,17 @@ export function lintNode(dir, pkg) {
137
147
  // Both halves matter: a node with inputs but no outputs is still an error, because something
138
148
  // reaches it and then the data stops dead.
139
149
  const isAnnotation = !outputs.size && !(iface.inputs ?? []).length && !api;
140
- if (!outputs.size && !api?.service && !isAnnotation)
150
+ /**
151
+ * AN ATTACHMENT is the third legitimate case, and the only one that is DECLARED rather than
152
+ * inferred from an absence. It clips onto another node and overrides that node's config
153
+ * (DECLARATIVE_NODES.md §14). It never runs, so it has no inputs, no outputs and no api, and
154
+ * it has no place in the data graph at all: there is nothing to wire it to.
155
+ *
156
+ * Declared rather than inferred because an attachment and a half-finished node look identical
157
+ * from the outside, and the whole point of `kind` is that a node says what it is.
158
+ */
159
+ const isAttachment = node.kind === "Attachment" || !!iface.attaches;
160
+ if (!outputs.size && !api?.service && !isAnnotation && !isAttachment)
141
161
  report("error", F.iface ?? F.node, `declares no outputs. A node that emits nothing cannot be wired to anything`);
142
162
 
143
163
  // The service channel must match what the node advertises. A method with no
@@ -160,6 +180,10 @@ export function lintNode(dir, pkg) {
160
180
  * advertised or nothing can reach it.
161
181
  */
162
182
  const DISCOVERY_METHOD = "getSchema";
183
+ /**
184
+ * A MENU entry names a component and nothing else (owner ruling 2026-09-20): the panel
185
+ * RUNS this node and draws what its events emitted, so there is no method to check here.
186
+ */
163
187
  for (const method of Object.keys(api?.service ?? {}))
164
188
  if (method !== DISCOVERY_METHOD && !advertised.has(method))
165
189
  report("error", F.api, `api/service.yaml has "${method}" but no serviceConnector with isService: true lists it in methods. Nothing can discover it`);
@@ -182,6 +206,21 @@ export function lintNode(dir, pkg) {
182
206
  `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 (https://docs.unoverse.ai/nodes/manifest-nodes)`,
183
207
  );
184
208
 
209
+ /**
210
+ * A REPLY NEVER READS CONFIG (owner ruling 2026-09-20). A method's `returns` and its
211
+ * `renderComponents` go to a MODEL or a screen, and a box's settings are the author's:
212
+ * the runtime no longer puts `config` in that scope, so an expression reading it fails
213
+ * at run time with "config is not defined". Said here instead, at write time, with the
214
+ * reason. The CALLS still read config to build their requests; that is server-side.
215
+ */
216
+ for (const [method, spec] of Object.entries(api?.service ?? {})) {
217
+ for (const key of ["returns", "renderComponents"]) {
218
+ const text = typeof spec?.[key] === "string" ? spec[key] : spec?.[key] ? JSON.stringify(spec[key]) : "";
219
+ if (/\bconfig\./.test(text))
220
+ report("error", F.api, `api/service.yaml "${method}.${key}" reads config. A reply goes to a model and a box's settings are the author's, never the model's: compute what the caller may know in a call, or answer from params and the reply (https://docs.unoverse.ai/nodes/mcp-services)`);
221
+ }
222
+ }
223
+
185
224
  // The mirror of it: advertising the discovery method as a tool.
186
225
  if (advertised.has(DISCOVERY_METHOD))
187
226
  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 (https://docs.unoverse.ai/nodes/mcp-services)`);
@@ -192,6 +231,52 @@ export function lintNode(dir, pkg) {
192
231
 
193
232
  // ── tier 3: semantics ──
194
233
 
234
+ /**
235
+ * ATTACHMENTS (DECLARATIVE_NODES.md §14). An attachment is configuration that arrives from
236
+ * outside a node and LOCKS what it owns: it clips onto the edge of a node whose configSchema
237
+ * has every field it sets, overrides those fields, and contributes a tab to that node's panel.
238
+ *
239
+ * The two halves must agree. `kind: Attachment` with no `attaches` is a node that clips onto
240
+ * nothing; `attaches` without the kind is an attachment the canvas will draw as an ordinary
241
+ * wireable box, which is the exact failure the guardrail ruling named ("a guardrail you must
242
+ * remember to wire is a suggestion").
243
+ */
244
+ if (node.kind === "Attachment" && !iface.attaches)
245
+ report("error", F.iface ?? F.node, `kind is "Attachment" but interface.yaml declares no "attaches". An attachment that sets nothing clips onto nothing (https://docs.unoverse.ai/nodes/manifest-nodes)`);
246
+ if (iface.attaches && node.kind !== "Attachment")
247
+ report("error", F.node, `declares "attaches" but kind is "${node.kind}". An attachment must SAY so, or the canvas draws it as an ordinary wireable box (https://docs.unoverse.ai/nodes/manifest-nodes)`);
248
+
249
+ if (isAttachment) {
250
+ // IT NEVER RUNS. Everything below is a way of running, and an attachment that declared one
251
+ // would be a node pretending to be data.
252
+ for (const [what, present, file] of [
253
+ ["inputs", (iface.inputs ?? []).length, F.iface],
254
+ ["outputs", outputs.size, F.iface],
255
+ ["serviceConnectors", (iface.serviceConnectors ?? []).length, F.iface],
256
+ ["api/run.yaml", api?.run?.length, F.api],
257
+ ["api/service.yaml", api?.service && Object.keys(api.service).length, F.api],
258
+ ["menu", (node.menu ?? []).length, F.node],
259
+ ])
260
+ if (present)
261
+ report("error", file ?? F.node, `is an Attachment but declares ${what}. An attachment never runs and has no place in the data graph: it sets config on the node it clips onto (https://docs.unoverse.ai/nodes/manifest-nodes)`);
262
+
263
+ /**
264
+ * ONE OWNER PER VALUE, applied to the attachment itself. A field it SETS on a host and also
265
+ * declares in its OWN configSchema is two surfaces editing one name, and the reader cannot
266
+ * tell which one is the lock. Anything variable belongs in its own config; anything it owns
267
+ * on the host is a literal.
268
+ */
269
+ const sets = Object.keys(iface.attaches?.sets ?? {});
270
+ const own = new Set(Object.keys(config?.configSchema?.properties ?? {}));
271
+ for (const f of sets.filter((f) => own.has(f)))
272
+ report("error", F.config ?? F.node, `sets "${f}" on its host AND declares it in its own configSchema. One owner per value: set it on the host, or own it here, never both (https://docs.unoverse.ai/nodes/manifest-nodes)`);
273
+
274
+ if (sets.length) attachments.set(label, { file: F.iface ?? F.node, sets, type: node.type });
275
+ } else if (config?.configSchema?.properties) {
276
+ // The other half of the cross-node check below: what a host has to offer an attachment.
277
+ configFields.set(label, new Set(Object.keys(config.configSchema.properties)));
278
+ }
279
+
195
280
  // kind is DECLARED but VERIFIED. Getting it wrong used to surface only at runtime.
196
281
  // A tool exchange is a multi-turn LOOP, so it makes the node a CallbackNode just as
197
282
  // surely as a streaming transport does, and it must name a connector that grants tools.
@@ -232,7 +317,7 @@ export function lintNode(dir, pkg) {
232
317
  // way anything leaves the node, so without it the node is a call into the void.
233
318
  if (!(api.events ?? []).length)
234
319
  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 (https://docs.unoverse.ai/nodes/manifest-nodes)`);
235
- } else if (existsSync(join(dir, "src")) === false && !parts.api && !isAnnotation) {
320
+ } else if (existsSync(join(dir, "src")) === false && !parts.api && !isAnnotation && !isAttachment) {
236
321
  // An ANNOTATION node is exempt, because "does nothing" is the whole specification. `Note`
237
322
  // has no inputs, no outputs and no api: it is a markdown sticky on the canvas. Warning about
238
323
  // it would train a reader to ignore this message, which is the only thing that makes it
@@ -390,6 +475,22 @@ export function lintNode(dir, pkg) {
390
475
  if (s?.poll?.body && s.poll.method !== "POST")
391
476
  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
477
 
478
+ // A REPEAT entry makes no request of its own; its turn's calls do. It needs a turn, a way
479
+ // to stop and a bound, or it would make nothing, or never end until the platform's cap.
480
+ if (s?.repeat) {
481
+ if (!Array.isArray(s.repeat.calls) || !s.repeat.calls.length)
482
+ report("error", F.api, `${at}[${i}] (${s.name}) repeats with no calls, so a turn would do nothing (https://docs.unoverse.ai/nodes/calls-that-loop)`);
483
+ if (!s.repeat.until)
484
+ report("error", F.api, `${at}[${i}] (${s.name}) repeats with no "until", so only the turn bound would end it`);
485
+ if (s.repeat.maxTurns === undefined)
486
+ report("error", F.api, `${at}[${i}] (${s.name}) repeats with no "maxTurns"; the author's bound is compulsory, the platform's cap of 20 is only a backstop`);
487
+ if (Array.isArray(s.repeat.calls) && s.repeat.calls.some((c) => c?.repeat))
488
+ report("error", F.api, `${at}[${i}] (${s.name}) has a repeat inside its turn. Nested repeats multiply their bounds; a walk that needs a walk inside it is two nodes`);
489
+ for (const k of ["method", "url", "transport", "credential", "body", "retry", "headers", "query"])
490
+ if (k in s) report("error", F.api, `${at}[${i}] (${s.name}) sets "${k}" beside "repeat". A repeat makes no request of its own; its turn's calls do`);
491
+ continue;
492
+ }
493
+
393
494
  // A LOOP entry keeps an index rather than reaching a host, so the request keys are as
394
495
  // meaningless on it as they are on a state call.
395
496
  if (s?.loop) {
@@ -431,8 +532,17 @@ export function lintNode(dir, pkg) {
431
532
 
432
533
  // A step naming one that does not exist reads as empty, and the request goes out
433
534
  // 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)) {
535
+ // A REPEAT is read in two parts. Its turn's calls are a chain of their own (checked there),
536
+ // and its `until` reads THAT turn's calls, never this list's; only what is left of it is
537
+ // read against this list.
538
+ for (const [i, s] of list.entries()) {
539
+ if (s?.repeat) {
540
+ const turn = Array.isArray(s.repeat.calls) ? s.repeat.calls.map((c) => c?.name) : [];
541
+ for (const [, ref] of String(s.repeat.until ?? "").matchAll(/\bcalls\.([A-Za-z0-9_]+)/g))
542
+ if (!turn.includes(ref)) report("error", F.api, `${at}[${i}] (${s.name}).repeat.until reads calls.${ref}, which is not a call in its turn`);
543
+ }
544
+ const own = s?.repeat ? { ...s, repeat: undefined } : s;
545
+ for (const [, ref] of JSON.stringify(own ?? {}).matchAll(/\bcalls\.([A-Za-z0-9_]+)/g)) {
436
546
  const earlier = names.slice(0, i);
437
547
  if (!earlier.includes(ref))
438
548
  report(
@@ -443,6 +553,7 @@ export function lintNode(dir, pkg) {
443
553
  : `${at}[${i}] (${s.name}) reads calls.${ref}, which is not a call in this list`,
444
554
  );
445
555
  }
556
+ }
446
557
  }
447
558
 
448
559
  // The narrator's call is not part of a list: nothing reads its reply back and it can
@@ -467,7 +578,7 @@ export function lintNode(dir, pkg) {
467
578
  report("error", F.api, `service.${name} declares "${old}". A method's calls are ALWAYS a list named "calls", even when there is one (https://docs.unoverse.ai/nodes/manifest-nodes)`);
468
579
 
469
580
  // Credentials must resolve to a declared type, or the run fails at execute time.
470
- for (const c of iface.credentials ?? []) {
581
+ for (const c of [...(iface.credentials ?? []), ...(iface.attaches?.credentials ?? [])]) {
471
582
  const name = c.name;
472
583
  if (!pkg.credentials.has(name))
473
584
  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 (https://docs.unoverse.ai/nodes/credentials)`);
@@ -561,7 +672,7 @@ export function lintNode(dir, pkg) {
561
672
  for (const [, b] of body.matchAll(/\b(?:prompt|blocks)\.([A-Za-z0-9_]+)/g))
562
673
  if (!promptBlocks().has(b) && !seen.has(b)) {
563
674
  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`);
675
+ report("error", F.api, `references prompt block "{{prompt.${b}}}", which does not exist in design/marketplace/blocks/. It resolves to empty and the instruction silently disappears`);
565
676
  }
566
677
  }
567
678
  }
@@ -758,7 +869,46 @@ export function lintNode(dir, pkg) {
758
869
  // Nothing errors, and every downstream field reads empty.
759
870
  if (call.poll && pkg.requires.poll !== true)
760
871
  report("error", rel(pkg.file), `node ${node.type} waits on a job at ${at} but package.yaml requires.poll is not true`);
872
+ // A REPEATED call on an executor without it makes no request at all and settles on
873
+ // nothing: the node answers empty and nothing errors.
874
+ if (call.repeat && pkg.requires.repeat !== true)
875
+ report("error", rel(pkg.file), `node ${node.type} goes round in turns at ${at} but package.yaml requires.repeat is not true`);
761
876
  }
762
877
 
763
878
  return node;
764
879
  }
880
+
881
+ /**
882
+ * THE LAST PASS: can each attachment ever clip onto anything?
883
+ *
884
+ * An attachment names no host. Compatibility IS the fields (DECLARATIVE_NODES.md §14): a node
885
+ * accepts it when that node's configSchema declares every field the attachment sets. That is
886
+ * what keeps the mechanism general — no `accepts:` list on the host to drift, no enum of kinds
887
+ * to widen for each new case — but it means the answer is not in the attachment's own folder.
888
+ * Its host may be in a package read later in the run, or in no package at all.
889
+ *
890
+ * So it is checked once, after every package, and it catches the mistake this design makes
891
+ * easy: a typo in a field name is not an error anywhere. The attachment lints clean, publishes,
892
+ * and then simply refuses to stick to anything, with nothing to read that says why.
893
+ *
894
+ * Reported as a WARNING, not an error. A universe may legitimately hold an attachment whose
895
+ * host lives in another universe, or one published ahead of the node it is for.
896
+ */
897
+ export function lintAttachments() {
898
+ for (const [label, { file, sets, type }] of attachments) {
899
+ const hosts = [...configFields].filter(([, fields]) => sets.every((f) => fields.has(f)));
900
+ if (hosts.length) continue;
901
+
902
+ // Which field is the reason? The one the fewest nodes have is almost always the typo, and
903
+ // naming it is the difference between a usable warning and "it does not work".
904
+ const rarest = sets
905
+ .map((f) => ({ f, n: [...configFields.values()].filter((fields) => fields.has(f)).length }))
906
+ .sort((a, b) => a.n - b.n)[0];
907
+ const why = rarest?.n === 0 ? `no node declares "${rarest.f}"` : `no single node declares all of ${sets.map((f) => `"${f}"`).join(", ")}`;
908
+ report(
909
+ "warn",
910
+ file ?? label,
911
+ `attachment ${type ?? label} can clip onto nothing: ${why}. An attachment fits a node whose configSchema has every field it sets (https://docs.unoverse.ai/nodes/manifest-nodes)`,
912
+ );
913
+ }
914
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Types for the objects and pipelines linter, which is plain JavaScript.
3
+ *
4
+ * Same arrangement as ../design/index.d.mts and ../nodes/index.d.mts: the linter stays
5
+ * .mjs so the CLIs run it under bare node, and TypeScript consumers (the publish gate,
6
+ * Studio) get a shape rather than `any`.
7
+ */
8
+
9
+ export interface Finding {
10
+ level: "error" | "warn" | "hint";
11
+ file: string;
12
+ line?: number;
13
+ msg: string;
14
+ }
15
+
16
+ export interface ObjectsResult {
17
+ problems: Finding[];
18
+ objectCount: number;
19
+ pipelineCount: number;
20
+ designRoot: string;
21
+ }
22
+
23
+ export interface LintObjectsOptions {
24
+ /** Absolute path -> text that stands in for what is on disk (Studio's unsaved editor). */
25
+ overlay?: Record<string, string>;
26
+ }
27
+
28
+ /** Lint every object and pipeline folder under `designRoot`, every tier. Prints nothing, throws nothing for a lint failure. */
29
+ export function lintObjects(designRoot: string, options?: LintObjectsOptions): ObjectsResult;
30
+
31
+ /** True when anything would fail a build. */
32
+ export function hasErrors(result: ObjectsResult): boolean;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The objects and pipelines linter, as a LIBRARY. Rules in, findings out.
3
+ *
4
+ * A THIRD SIBLING of ../design/ and ../nodes/ (docs/unoverse/data/objects/README.md 9a,
5
+ * 2026-09-10). An object and a pipeline are folders in the node grammar, so they are linted
6
+ * the way nodes are: tier 2, every file against a schema (packages/docs/schemas/objects and
7
+ * /pipelines); tier 3, the rules a schema cannot say (rules.mjs, the SAME functions the
8
+ * loaders refuse a file with). Not the design linter's way: there is no primitive tree here.
9
+ *
10
+ * Callers: `unoverse lint` and `POST /publish` through items/publish lintForPublish; Studio's
11
+ * save and draft lint; CI. Prints nothing, exits nothing, throws nothing for a lint failure.
12
+ *
13
+ * `options.overlay` maps an absolute path to text that STANDS IN for what is on disk, so
14
+ * Studio's editor lints what the developer has typed before it is saved, through these
15
+ * rules and no others.
16
+ */
17
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
18
+ import { join, resolve, dirname, relative, basename } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { createRequire } from "node:module";
21
+ import { problems, reset, report, state } from "../context.mjs";
22
+ import { validateAgainst, Validator } from "../nodes/schema.mjs";
23
+ import {
24
+ envelopeFindings, propsFindings, fromFindings, serviceFindings, makeFindings, rowKeyFindings, objectTestFindings,
25
+ pipelineFindings, pipelineFolderFindings,
26
+ } from "./rules.mjs";
27
+
28
+ const require_ = createRequire(import.meta.url);
29
+ let YAML;
30
+ try {
31
+ YAML = require_("yaml");
32
+ } catch {
33
+ throw new Error("the objects linter needs yaml (npm install -D yaml)");
34
+ }
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+
38
+ export const SCHEMA_ID = {
39
+ object: "https://docs.unoverse.ai/schemas/objects/object.schema.json",
40
+ props: "https://docs.unoverse.ai/schemas/objects/props.schema.json",
41
+ service: "https://docs.unoverse.ai/schemas/objects/service.schema.json",
42
+ make: "https://docs.unoverse.ai/schemas/objects/make.schema.json",
43
+ objectTest: "https://docs.unoverse.ai/schemas/objects/test.schema.json",
44
+ dataset: "https://docs.unoverse.ai/schemas/objects/dataset.schema.json",
45
+ pipeline: "https://docs.unoverse.ai/schemas/pipelines/pipeline.schema.json",
46
+ config: "https://docs.unoverse.ai/schemas/pipelines/config.schema.json",
47
+ interface: "https://docs.unoverse.ai/schemas/pipelines/interface.schema.json",
48
+ pipelineTest: "https://docs.unoverse.ai/schemas/pipelines/test.schema.json",
49
+ };
50
+
51
+ /**
52
+ * Where the schemas are, for this run. Their home is packages/docs/schemas/{objects,
53
+ * pipelines,nodes}, served by the docs site; in dist they sit beside this file
54
+ * (copy-lint.mjs puts them there, the CLI vendors that folder). The node schemas ride too:
55
+ * a pipeline's config, interface and test files ARE a node's, and the object schemas
56
+ * reach into _defs for an expression.
57
+ */
58
+ function schemaDirs() {
59
+ const out = [];
60
+ for (const kind of ["objects", "pipelines", "nodes"]) {
61
+ for (const c of [join(HERE, "_schema", kind), resolve(HERE, `../../../../docs/schemas/${kind}`), join(HERE, "../nodes/_schema")]) {
62
+ if (existsSync(c)) {
63
+ out.push(c);
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function loadAllSchemas() {
72
+ for (const dir of schemaDirs())
73
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".json")))
74
+ try {
75
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
76
+ if (s.$id && !state.schemas[s.$id]) state.schemas[s.$id] = s;
77
+ } catch (e) {
78
+ report("error", join(dir, f), `is not valid JSON: ${e.message}`);
79
+ }
80
+ }
81
+
82
+ const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
83
+ const isRecord = (x) => !!x && typeof x === "object" && !Array.isArray(x);
84
+
85
+ /**
86
+ * Lint every object and pipeline folder under `designRoot`: the marketplace tier and every
87
+ * org's. Returns `{ problems, objectCount, pipelineCount, designRoot }`.
88
+ */
89
+ export function lintObjects(designRoot, options = {}) {
90
+ const ROOT = resolve(designRoot);
91
+ reset(ROOT, "");
92
+ const overlay = {};
93
+ for (const [k, v] of Object.entries(options.overlay ?? {})) overlay[resolve(k)] = v;
94
+ const readText = (f) => (overlay[resolve(f)] === undefined ? readFileSync(f, "utf8") : overlay[resolve(f)]);
95
+ const rel = (f) => relative(process.cwd(), f).startsWith("..") ? f : relative(process.cwd(), f);
96
+
97
+ const readYaml = (file) => {
98
+ if (!existsSync(file) && overlay[resolve(file)] === undefined) return null;
99
+ try {
100
+ const doc = YAML.parse(readText(file));
101
+ if (doc && typeof doc === "object") delete doc.$schema;
102
+ return doc ?? {};
103
+ } catch (e) {
104
+ report("error", rel(file), `is not valid YAML: ${e.message.split("\n")[0]}`);
105
+ return null;
106
+ }
107
+ };
108
+
109
+ if (!isDir(ROOT)) {
110
+ report("error", ROOT, "no design/ folder here");
111
+ return { problems: [...problems], objectCount: 0, pipelineCount: 0, designRoot: ROOT };
112
+ }
113
+ if (!Validator) {
114
+ report("error", ROOT, "the objects linter needs @cfworker/json-schema");
115
+ return { problems: [...problems], objectCount: 0, pipelineCount: 0, designRoot: ROOT };
116
+ }
117
+ loadAllSchemas();
118
+
119
+ // ── the tiers: the marketplace, then every org folder ──
120
+ const marketplace = join(ROOT, "marketplace");
121
+ const tiers = [];
122
+ if (isDir(marketplace)) tiers.push({ org: undefined, dir: marketplace });
123
+ for (const e of readdirSync(ROOT)) {
124
+ if (e.startsWith(".") || e === "marketplace" || e === "_schema") continue;
125
+ const d = join(ROOT, e);
126
+ if (isDir(d)) tiers.push({ org: e, dir: d });
127
+ }
128
+
129
+ /**
130
+ * WHAT A TIER CAN SEE. A node named by a call or a collect step must be one the org
131
+ * has: the marketplace's, or its own under design/<org>/nodes/. A developer's workspace
132
+ * carries no marketplace on disk (it is installed, never baked), so when the marketplace
133
+ * tier is absent the existence rules step back to a hint rather than reporting every
134
+ * shipped node as unknown: a rule that cannot see must not read that as clean, nor as
135
+ * wrong.
136
+ */
137
+ const nodeTypesIn = (tierDir) => {
138
+ const out = new Set();
139
+ const home = join(tierDir, "nodes");
140
+ if (!isDir(home)) return out;
141
+ for (const pkg of readdirSync(home)) {
142
+ const nodes = join(home, pkg, "nodes");
143
+ if (!isDir(nodes)) continue;
144
+ for (const n of readdirSync(nodes)) {
145
+ const nodeYaml = join(nodes, n, "node.yaml");
146
+ if (!existsSync(nodeYaml)) continue;
147
+ const m = /^type:\s*([A-Za-z0-9_]+)/m.exec(readFileSync(nodeYaml, "utf8"));
148
+ out.add(m ? m[1] : n);
149
+ }
150
+ }
151
+ return out;
152
+ };
153
+ const foldersIn = (tierDir, kind) => {
154
+ const home = join(tierDir, kind);
155
+ if (!isDir(home)) return [];
156
+ return readdirSync(home).filter((e) => !e.startsWith(".") && !e.startsWith("_") && isDir(join(home, e)));
157
+ };
158
+ const marketplaceSeen = tiers.some((t) => t.org === undefined);
159
+ const marketplaceNodes = marketplaceSeen ? nodeTypesIn(marketplace) : null;
160
+ const marketplaceObjects = new Set(marketplaceSeen ? foldersIn(marketplace, "objects") : []);
161
+ const marketplacePipelines = new Set(marketplaceSeen ? foldersIn(marketplace, "pipelines") : []);
162
+ const visible = (tier, kind) => {
163
+ const own = new Set(foldersIn(tier.dir, kind));
164
+ if (!marketplaceSeen) return null;
165
+ for (const n of kind === "objects" ? marketplaceObjects : marketplacePipelines) own.add(n);
166
+ return own;
167
+ };
168
+ const visibleNodes = (tier) => {
169
+ if (!marketplaceSeen) return null;
170
+ const s = new Set(marketplaceNodes);
171
+ if (tier.org) for (const n of nodeTypesIn(tier.dir)) s.add(n);
172
+ return s;
173
+ };
174
+
175
+ // ── objects ──
176
+ // An object's folder, read raw: envelope, own props, service, test. The lint composes
177
+ // `extends` itself (across tiers, the org's first) so the row keys can be checked against
178
+ // every field the object carries, the base's included.
179
+ const objectFolder = (dir, name) => {
180
+ const envFile = [`${name}.yaml`, `${name}.json`].map((f) => join(dir, f)).find((f) => existsSync(f) || overlay[resolve(f)] !== undefined);
181
+ if (!envFile) return null;
182
+ const envelope = readYaml(envFile);
183
+ let props = isRecord(envelope) ? envelope.props : undefined;
184
+ let propsFile = envFile;
185
+ if (isRecord(props) && typeof props.$include === "string") {
186
+ propsFile = join(dir, `${props.$include}.yaml`);
187
+ props = readYaml(propsFile);
188
+ if (props === null && !existsSync(propsFile)) report("error", rel(envFile), `object ${name}: props $include "${envelope.props.$include}" resolves to no file in the folder`);
189
+ }
190
+ const servicePath = join(dir, "api", "service.yaml");
191
+ const service = existsSync(servicePath) || overlay[resolve(servicePath)] !== undefined ? readYaml(servicePath) : undefined;
192
+ // THE PRIVATE MAKE FUNCTION (objects README 4b, 2026-09-14): api/make.yaml, one function, `make`.
193
+ const makePath = join(dir, "api", "make.yaml");
194
+ const make = existsSync(makePath) || overlay[resolve(makePath)] !== undefined ? readYaml(makePath) : undefined;
195
+ const testPath = join(dir, "test.yaml");
196
+ const test = existsSync(testPath) || overlay[resolve(testPath)] !== undefined ? readYaml(testPath) : undefined;
197
+ // THE TABLE IT OWNS (objects README 4f, dataset.md): dataset.yaml, never table.yaml, which
198
+ // would be the envelope of an object NAMED table.
199
+ const datasetPath = join(dir, "dataset.yaml");
200
+ const dataset = datasetPath !== envFile && (existsSync(datasetPath) || overlay[resolve(datasetPath)] !== undefined) ? readYaml(datasetPath) : undefined;
201
+ return { dir, name, envFile, envelope, props, propsFile, service, servicePath, make, makePath, test, testPath, dataset, datasetPath };
202
+ };
203
+ const objectIndex = new Map(); // `${org ?? ""}/${name}` -> folder
204
+ const datasetTables = new Map(); // the table a dataset would own -> the object that owns it
205
+ for (const tier of tiers) for (const name of foldersIn(tier.dir, "objects")) {
206
+ const f = objectFolder(join(tier.dir, "objects", name), name);
207
+ if (f) objectIndex.set(`${tier.org ?? ""}/${name}`, { ...f, org: tier.org });
208
+ }
209
+ const findObject = (name, org) => objectIndex.get(`${org ?? ""}/${name}`) ?? objectIndex.get(`/${name}`) ?? null;
210
+ /** The composed props of an object, or null on a cycle or a missing base (reported by the caller). */
211
+ const composedProps = (folder, chain = []) => {
212
+ const own = isRecord(folder.props) ? folder.props : {};
213
+ const base = isRecord(folder.envelope) ? folder.envelope.extends : undefined;
214
+ if (typeof base !== "string") return { ...own };
215
+ if (chain.includes(folder.name) || chain.includes(base)) return null;
216
+ const parent = findObject(base, folder.org);
217
+ if (!parent) return null;
218
+ const pp = composedProps(parent, [...chain, folder.name]);
219
+ return pp ? { ...pp, ...own } : null;
220
+ };
221
+
222
+ /** An object's own functions by name: api/service.yaml's, and `make` from api/make.yaml. */
223
+ const ownFunctions = (folder) => ({ ...(isRecord(folder.service) ? folder.service : {}), ...(folder.make != null ? { make: folder.make } : {}) });
224
+ /** The functions an object can call: its own over every base's (objects README 5, "Inheritance is whole"). */
225
+ const composedService = (folder, chain = []) => {
226
+ const own = ownFunctions(folder);
227
+ const base = isRecord(folder.envelope) ? folder.envelope.extends : undefined;
228
+ if (typeof base !== "string" || chain.includes(folder.name) || chain.includes(base)) return { ...own };
229
+ const parent = findObject(base, folder.org);
230
+ return parent ? { ...composedService(parent, [...chain, folder.name]), ...own } : { ...own };
231
+ };
232
+
233
+ let objectCount = 0;
234
+ for (const [, f] of objectIndex) {
235
+ objectCount++;
236
+ const tier = { org: f.org, dir: f.org ? join(ROOT, f.org) : marketplace };
237
+ const at = (file) => rel(join(f.dir, file));
238
+ if (f.envelope === null) continue;
239
+ validateAgainst(SCHEMA_ID.object, f.envelope, rel(f.envFile), `object ${f.name}: the envelope`);
240
+ if (f.props != null && f.propsFile !== f.envFile) validateAgainst(SCHEMA_ID.props, f.props, rel(f.propsFile), `object ${f.name}: the props`);
241
+ if (f.service != null) validateAgainst(SCHEMA_ID.service, f.service, rel(f.servicePath), `object ${f.name}: the functions`);
242
+ if (f.make != null) validateAgainst(SCHEMA_ID.make, f.make, rel(f.makePath), `object ${f.name}: the make function`);
243
+ if (f.test != null) validateAgainst(SCHEMA_ID.objectTest, f.test, rel(f.testPath), `object ${f.name}: the test`);
244
+ if (f.dataset != null) validateAgainst(SCHEMA_ID.dataset, f.dataset, rel(f.datasetPath), `object ${f.name}: the table it owns`);
245
+ // TWO OBJECTS MAY NOT ARRIVE AT ONE TABLE. A dataset's table is named from its object and its
246
+ // org, squeezed to letters, digits and underscores, so "my-thing" and "my_thing" would be one
247
+ // table and the second object would quietly write into the first's rows.
248
+ if (f.dataset != null) {
249
+ const table = `dataset_${[f.org, f.name].filter(Boolean).map((p) => String(p).replace(/[^a-z0-9]+/gi, "_").toLowerCase()).join("_")}`;
250
+ const taken = datasetTables.get(table);
251
+ if (taken && taken !== `${f.org ?? ""}/${f.name}`) report("error", rel(f.datasetPath), `object ${f.name}: its table would be "${table}", which "${taken}" already owns. Two objects cannot share a table: rename one.`);
252
+ else datasetTables.set(table, `${f.org ?? ""}/${f.name}`);
253
+ }
254
+
255
+ const say = (list) => {
256
+ for (const x of list) report(x.level ?? "error", at(x.file), x.msg);
257
+ };
258
+ say(envelopeFindings(f.name, f.envelope, basename(f.envFile)));
259
+
260
+ // extends: resolves, no cycle, across the tiers the org can see.
261
+ let parentProps = null;
262
+ const base = isRecord(f.envelope) ? f.envelope.extends : undefined;
263
+ if (typeof base === "string") {
264
+ const parent = findObject(base, f.org);
265
+ if (!parent) report("error", rel(f.envFile), `object ${f.name}: extends "${base}", which no tier holds`);
266
+ else {
267
+ parentProps = composedProps(parent, [f.name]);
268
+ if (parentProps === null) report("error", rel(f.envFile), `object ${f.name}: extends cycle through ${base}`);
269
+ }
270
+ }
271
+ say(propsFindings(f.name, f.props, parentProps, base, basename(f.propsFile)));
272
+ if (isRecord(f.props)) for (const [k, p] of Object.entries(f.props)) if (isRecord(p) && p.from != null) say(fromFindings(f.name, k, p.from, composedService(f), basename(f.propsFile)));
273
+ say(serviceFindings(f.name, f.service, visibleNodes(tier)));
274
+ say(makeFindings(f.name, f.make, f.service, visibleNodes(tier)));
275
+ if (!marketplaceSeen && isRecord(f.service)) report("hint", rel(f.servicePath), `object ${f.name}: the marketplace is not on disk here, so the nodes its calls name were not checked`);
276
+ const all = composedProps(f) ?? (isRecord(f.props) ? f.props : {});
277
+ say(rowKeyFindings(f.name, f.envelope, all, basename(f.envFile)));
278
+ say(objectTestFindings(f.name, f.test, ownFunctions(f)));
279
+ // An org may never take a marketplace name: a bare object_type must stay unambiguous.
280
+ if (f.org && marketplaceObjects.has(f.name)) report("error", rel(f.envFile), `object ${f.name}: an org's object may not take a marketplace object's name; the row's object_type would be ambiguous. Rename it, or extend ${f.name} under a name of its own`);
281
+ }
282
+
283
+ // ── pipelines ──
284
+ let pipelineCount = 0;
285
+ for (const tier of tiers) for (const name of foldersIn(tier.dir, "pipelines")) {
286
+ pipelineCount++;
287
+ const dir = join(tier.dir, "pipelines", name);
288
+ const at = (file) => rel(join(dir, file));
289
+ const filePath = join(dir, "pipeline.yaml");
290
+ const file = readYaml(filePath);
291
+ if (file === null) {
292
+ if (!existsSync(filePath)) report("error", at("pipeline.yaml"), `pipeline ${name}: the folder has no pipeline.yaml`);
293
+ continue;
294
+ }
295
+ const configPath = join(dir, "config.yaml");
296
+ const config = existsSync(configPath) || overlay[resolve(configPath)] !== undefined ? readYaml(configPath) : undefined;
297
+ const ifacePath = join(dir, "interface.yaml");
298
+ const iface = existsSync(ifacePath) || overlay[resolve(ifacePath)] !== undefined ? readYaml(ifacePath) : undefined;
299
+ const testPath = join(dir, "test.yaml");
300
+ const test = existsSync(testPath) || overlay[resolve(testPath)] !== undefined ? readYaml(testPath) : undefined;
301
+
302
+ validateAgainst(SCHEMA_ID.pipeline, file, at("pipeline.yaml"), `pipeline ${name}`);
303
+ if (config != null) validateAgainst(SCHEMA_ID.config, config, at("config.yaml"), `pipeline ${name}: the Import form`);
304
+ if (iface != null) validateAgainst(SCHEMA_ID.interface, iface, at("interface.yaml"), `pipeline ${name}: the interface`);
305
+ if (test != null) validateAgainst(SCHEMA_ID.pipelineTest, test, at("test.yaml"), `pipeline ${name}: the test`);
306
+
307
+ const say = (list) => {
308
+ for (const x of list) report(x.level ?? "error", at(x.file), x.msg);
309
+ };
310
+ say(pipelineFindings(name, file));
311
+ say(pipelineFolderFindings(name, { file, config: config ?? null, test }, { nodeTypes: visibleNodes(tier), objectNames: visible(tier, "objects") }));
312
+ if (!marketplaceSeen) report("hint", at("pipeline.yaml"), `pipeline ${name}: the marketplace is not on disk here, so the nodes its steps name and the objects it makes were not checked`);
313
+ if (tier.org && marketplacePipelines.has(name)) report("error", at("pipeline.yaml"), `pipeline ${name}: an org's pipeline may not take a marketplace pipeline's name; the connector's name would be ambiguous`);
314
+ }
315
+
316
+ const rank = { error: 0, warn: 1, hint: 2 };
317
+ const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
318
+ return { problems: sorted, objectCount, pipelineCount, designRoot: ROOT };
319
+ }
320
+
321
+ /** True when anything would fail a build. Warnings and hints inform, errors stop. */
322
+ export function hasErrors(result) {
323
+ return result.problems.some((p) => p.level === "error");
324
+ }