@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.
- package/bin/studio.mjs +3 -3
- package/local/authoring.ts +1 -1
- package/local/catalog.ts +22 -2
- package/local/keys.ts +1 -1
- package/local/nodes.ts +29 -42
- package/local/plugin.ts +75 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +51 -22
- package/local/source.ts +4 -9
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityHeader.tsx +1 -1
- package/screens/IdentityView.tsx +206 -0
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +5 -2
- package/src/App.tsx +14 -4
- package/src/NodeKeys.tsx +1 -1
- package/src/host.ts +3 -16
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
- package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +33 -25
- package/vendor/base/src/items/publish.ts +31 -7
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +6 -1
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +81 -10
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +12 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/index.ts +1 -1
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/manifests/source.ts +1 -1
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/README.md +1 -1
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +31 -12
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- 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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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
|
+
}
|