@unoverse-platform/studio 0.1.97 → 0.1.99
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 +2 -2
- package/local/catalog.ts +5 -1
- package/local/nodes.ts +28 -41
- package/local/plugin.ts +71 -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 +43 -23
- package/local/source.ts +3 -8
- 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/IdentityView.tsx +3 -3
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +2 -0
- package/src/App.tsx +8 -3
- package/src/host.ts +1 -14
- 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/auth/auth.ts +35 -5
- package/vendor/base/src/definitions/isolated.ts +37 -0
- 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 +117 -0
- package/vendor/base/src/definitions/registry/catalog.ts +40 -24
- 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 +69 -29
- package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
- 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 +29 -25
- package/vendor/base/src/items/publish.ts +30 -6
- 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 +5 -2
- 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 +5 -0
- 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 +80 -9
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +9 -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/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/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/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 +30 -11
- 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/local/sample-identity/brand/brand.yaml +0 -88
- package/local/sample-identity/brand/manifest.yaml +0 -13
- package/local/sample-identity/organisation/manifest.yaml +0 -15
- package/local/sample-identity/organisation/organisation.yaml +0 -53
- package/local/sample-identity/purpose/manifest.yaml +0 -12
- package/local/sample-identity/purpose/purpose.yaml +0 -46
- package/local/sample-identity/story/manifest.yaml +0 -12
- package/local/sample-identity/story/story.yaml +0 -63
- package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
|
@@ -67,7 +67,7 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
67
67
|
try {
|
|
68
68
|
return (
|
|
69
69
|
statSync(d).isDirectory() &&
|
|
70
|
-
["components", "atoms", "styles", "apps"].some((s) => existsSync(join(d, s)))
|
|
70
|
+
["components", "atoms", "styles", "apps", "agents"].some((s) => existsSync(join(d, s)))
|
|
71
71
|
);
|
|
72
72
|
} catch {
|
|
73
73
|
return false;
|
|
@@ -96,9 +96,7 @@ export function lintDefinitions(designRoot, options = {}) {
|
|
|
96
96
|
const DS = (() => {
|
|
97
97
|
const onDisk = join(DESIGN_ROOT, "marketplace");
|
|
98
98
|
if (existsSync(onDisk)) return onDisk;
|
|
99
|
-
const nodesHome = resolve(DESIGN_ROOT, "..", "nodes");
|
|
100
99
|
for (const c of [
|
|
101
|
-
join(nodesHome, "marketplace", "definitions"),
|
|
102
100
|
join(resolve(DESIGN_ROOT, "..", "plugins"), "node_modules", "@unoverse-platform", "marketplace", "definitions"),
|
|
103
101
|
])
|
|
104
102
|
if (existsSync(c)) return c;
|
|
@@ -190,6 +188,9 @@ const isAppPath = (f) => homeSegments(f).includes("apps");
|
|
|
190
188
|
// carry no manifest and declare no state tree, the face being the filename (§9b rule 7).
|
|
191
189
|
const isTemplatePath = (f) => homeSegments(f).includes("templates");
|
|
192
190
|
const isTemplatePart = (f) => isTemplatePath(f) && homeSegments(f).includes("components");
|
|
191
|
+
// THE AGENT KIND (docs.unoverse.ai/design/tasks, 2026-09-06): one file under <org>/agents/,
|
|
192
|
+
// a workflow listed for the map. Not a UI tree, so the tree rules do not apply; its own check does.
|
|
193
|
+
const isAgentPath = (f) => homeSegments(f).includes("agents");
|
|
193
194
|
// The DEFINITION ROOT folder for a file: a bare partial lives one level under it
|
|
194
195
|
// (layouts/ states/ components/ blocks/); every `$include` resolves against it.
|
|
195
196
|
const defRoot = (file) => {
|
|
@@ -597,7 +598,7 @@ const keyHomes = [
|
|
|
597
598
|
[join(DS, "components"), true],
|
|
598
599
|
];
|
|
599
600
|
for (const orgDir of orgDirs) {
|
|
600
|
-
keyHomes.push([join(orgDir, "components"), true], [join(orgDir, "apps"), true]);
|
|
601
|
+
keyHomes.push([join(orgDir, "components"), true], [join(orgDir, "apps"), true], [join(orgDir, "agents"), false]);
|
|
601
602
|
}
|
|
602
603
|
for (const [dir, isDir] of keyHomes) {
|
|
603
604
|
if (!existsSync(dir)) continue;
|
|
@@ -638,7 +639,9 @@ for (const [dir, isDir] of keyHomes) {
|
|
|
638
639
|
// carries are ordinary components, so they were never exempt from anything.
|
|
639
640
|
const homes = [{ dir: join(DS, "components") }, { dir: join(DS, "atoms") }, { dir: join(DS, "templates") }];
|
|
640
641
|
for (const orgDir of orgDirs) {
|
|
641
|
-
|
|
642
|
+
// `agents` (2026-09-06): a kind is linted; a home not enumerated is a home where every
|
|
643
|
+
// rule is off, silently.
|
|
644
|
+
for (const sub of ["apps", "components", "templates", "agents"]) {
|
|
642
645
|
const d = join(orgDir, sub);
|
|
643
646
|
if (existsSync(d) && statSync(d).isDirectory()) homes.push({ dir: d });
|
|
644
647
|
}
|
|
@@ -701,64 +704,27 @@ for (const orgDir of orgDirs) {
|
|
|
701
704
|
);
|
|
702
705
|
}
|
|
703
706
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
* Declaring it makes whatever wears that face ADDRESSABLE: the pill goes up and the guest's
|
|
708
|
-
* next sentences travel to that instance instead of opening a new answer. Every finding here
|
|
709
|
-
* is something that produces a pill which looks fine and behaves wrongly, which is why they
|
|
710
|
-
* are errors rather than warnings.
|
|
711
|
-
*
|
|
712
|
-
* Read from the ENVELOPE first and the manifest second, the same order the loader reads the
|
|
713
|
-
* states tree in — a rule that judged only one home would pass whatever was authored in the
|
|
714
|
-
* other.
|
|
715
|
-
*/
|
|
716
|
-
const LATCH_KEYS = new Set(["title", "icon"]);
|
|
707
|
+
// ── `latch`, RETIRED (2026-09-07) ──
|
|
708
|
+
// The conversation is never "addressed at" a component: what is on screen is the server's
|
|
709
|
+
// fact and the model reads it. A leftover block is an intent nothing honours, so it errors.
|
|
717
710
|
for (const orgDir of orgDirs) {
|
|
718
711
|
const adir = join(orgDir, "apps");
|
|
719
712
|
if (!(existsSync(adir) && statSync(adir).isDirectory())) continue;
|
|
720
713
|
for (const e of readdirSync(adir)) {
|
|
721
714
|
const folder = join(adir, e);
|
|
722
|
-
let src, states;
|
|
723
715
|
try {
|
|
724
716
|
if (!statSync(folder).isDirectory()) continue;
|
|
725
|
-
|
|
726
|
-
const envelope = defPath(folder, e);
|
|
727
|
-
const manifest = defPath(folder, "manifest");
|
|
728
|
-
for (const p of [envelope, manifest]) {
|
|
717
|
+
for (const p of [defPath(folder, e), defPath(folder, "manifest")]) {
|
|
729
718
|
if (!p || !existsSync(p)) continue;
|
|
730
|
-
const
|
|
731
|
-
if (
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
719
|
+
const states = readDef(p).states;
|
|
720
|
+
if (!states || typeof states !== "object" || Array.isArray(states)) continue;
|
|
721
|
+
for (const [sname, sentry] of Object.entries(states))
|
|
722
|
+
if (sentry && typeof sentry === "object" && !Array.isArray(sentry) && sentry.latch !== undefined)
|
|
723
|
+
report("error", p, `state "${sname}": "latch" is retired (2026-09-07). Delete the block — the model reads what is on screen; nothing addresses a component`);
|
|
736
724
|
}
|
|
737
725
|
} catch {
|
|
738
726
|
continue; // a malformed envelope is reported elsewhere
|
|
739
727
|
}
|
|
740
|
-
if (!states) continue;
|
|
741
|
-
for (const [sname, sentry] of Object.entries(states)) {
|
|
742
|
-
const latch = sentry && typeof sentry === "object" && !Array.isArray(sentry) ? sentry.latch : undefined;
|
|
743
|
-
if (latch === undefined) continue;
|
|
744
|
-
if (typeof latch !== "object" || latch === null || Array.isArray(latch)) {
|
|
745
|
-
report("error", src, `state "${sname}": "latch" must be a block ({ title, icon }), not ${Array.isArray(latch) ? "a list" : typeof latch}. Presence is what makes a state addressable, so a malformed one is an intent nothing can honour (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`);
|
|
746
|
-
continue;
|
|
747
|
-
}
|
|
748
|
-
// The pill is the ONLY sign the guest has that their words are landing on this page
|
|
749
|
-
// rather than the conversation. Unlabelled, it says nothing — and the compiler drops
|
|
750
|
-
// a titleless block, so the state would silently stop being addressable.
|
|
751
|
-
if (typeof latch.title !== "string" || !latch.title.trim())
|
|
752
|
-
report("error", src, `state "${sname}": "latch" needs a "title" — it is what the pill reads, and the pill is the only thing telling the guest what they are addressing. Name the thing, not the verb ("Your Perfect Day", not "Edit the day")`);
|
|
753
|
-
const extra = Object.keys(latch).filter((k) => !LATCH_KEYS.has(k));
|
|
754
|
-
if (extra.length)
|
|
755
|
-
report("error", src, `state "${sname}": "latch" has no key(s) ${extra.join(", ")}. The block is exactly ${[...LATCH_KEYS].join(", ")} — an invented key is silently ignored, so whatever it was meant to do never happens. The pill's colours belong to the design system's composer-bar, never to a state`);
|
|
756
|
-
// A STATE NOTHING CAN ENTER CANNOT BE ADDRESSED. The base state is the app at rest —
|
|
757
|
-
// the conversation itself — and `stateOrder` excludes it, so `resolveLatch` can never
|
|
758
|
-
// rank a base-state instance and the pill would never appear.
|
|
759
|
-
if (sname === Object.keys(states)[0])
|
|
760
|
-
report("error", src, `state "${sname}": it is the BASE state — the app at rest, which the priority ladder excludes by construction (stateOrder is every state BUT the base). A latch here can never resolve, so no pill would ever draw. Declare it on a state something ARRIVES into`);
|
|
761
|
-
}
|
|
762
728
|
}
|
|
763
729
|
}
|
|
764
730
|
|
|
@@ -778,8 +744,14 @@ const PLATFORM_HANDLERS = new Set(["getDetail"]); // named handlers needing no f
|
|
|
778
744
|
// silently disable every rule after it.
|
|
779
745
|
const credentialDefs = new Set();
|
|
780
746
|
{
|
|
781
|
-
|
|
747
|
+
// Every tier's node packages: the marketplace's and each org's, all at <tier>/nodes/.
|
|
748
|
+
const nodesHomes = [];
|
|
782
749
|
try {
|
|
750
|
+
for (const tier of readdirSync(DESIGN_ROOT)) nodesHomes.push(join(DESIGN_ROOT, tier, "nodes"));
|
|
751
|
+
} catch {
|
|
752
|
+
/* no design root: nothing declares a credential */
|
|
753
|
+
}
|
|
754
|
+
for (const nodesHome of nodesHomes) try {
|
|
783
755
|
for (const pkg of readdirSync(nodesHome)) {
|
|
784
756
|
try {
|
|
785
757
|
for (const f of readdirSync(join(nodesHome, pkg, "credentials"))) if (isDefFile(f)) credentialDefs.add(defName(f));
|
|
@@ -859,24 +831,8 @@ for (const orgDir of [DS, ...orgDirs]) {
|
|
|
859
831
|
if (f.endsWith(".js") && !declared.has(f.slice(0, -3)))
|
|
860
832
|
report("error", join(folder, f), `${f} is a lifecycle handler no manifest opted into. Add "${f.slice(0, -3)}" to the manifest's lifecycle array, or delete the file — un-opted code never runs`);
|
|
861
833
|
|
|
862
|
-
/**
|
|
863
|
-
* LATCH, RETIRED FROM THE MANIFEST (2026-08-31, MCP guide §The Component Latch).
|
|
864
|
-
*
|
|
865
|
-
* It moved to the STATE, in the app's own tree, beside that state's `layout:` and
|
|
866
|
-
* `preview:`. A component said one thing about itself for the whole of its life, but a
|
|
867
|
-
* page is addressable when it IS the page and not when it is a summary in the flow, and
|
|
868
|
-
* only the state knows which of those is on screen.
|
|
869
|
-
*
|
|
870
|
-
* An ERROR rather than a silent ignore: nothing reads this key any more, so a manifest
|
|
871
|
-
* still carrying it declares an intent that never happens, and the author has no way to
|
|
872
|
-
* tell that from a latch that simply does not work.
|
|
873
|
-
*/
|
|
874
834
|
if (readDef(mf).latch !== undefined)
|
|
875
|
-
report(
|
|
876
|
-
"error",
|
|
877
|
-
mf,
|
|
878
|
-
`"latch" is no longer a manifest key — it moved to the STATE (2026-08-31). Declare it on the app state this component is addressable IN, beside that state's layout: \`states: { <state>: { layout: ..., latch: { title: "..." } } }\`. Nothing reads a manifest latch, so this one produces no pill (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`,
|
|
879
|
-
);
|
|
835
|
+
report("error", mf, `"latch" is retired (2026-09-07). Delete the key — nothing reads it`);
|
|
880
836
|
}
|
|
881
837
|
}
|
|
882
838
|
|
|
@@ -910,9 +866,20 @@ function readAppSizes(dir) {
|
|
|
910
866
|
try {
|
|
911
867
|
const sizes = {};
|
|
912
868
|
for (const [k, v] of Object.entries(readDef(p).appSize ?? {})) sizes[k] = typeof v === "string" ? v : v?.$value;
|
|
869
|
+
// `flex` IS THE SDK'S OWN WORD, NEVER A TOKEN (docs.unoverse.ai/design/apps): it means
|
|
870
|
+
// "fill the space remaining beside whatever core is open", on a slot and on a mounted
|
|
871
|
+
// template alike. A token named flex hard-codes ONE core's width and is wrong beside every
|
|
872
|
+
// other core: observed live 2026-09-10, an org's flex of 100% minus a 360px rail left a
|
|
873
|
+
// mounted grid overlapping a 480px voice core. The word is reserved; the file says so.
|
|
874
|
+
if (Object.prototype.hasOwnProperty.call(sizes, "flex"))
|
|
875
|
+
report("error", p, `app-sizes names "flex". flex is the SDK's own word (fill the space remaining beside whatever core is open), never a token: a token named flex hard-codes one core's width and is wrong beside every other core. Delete it; layouts keep writing appWidth: flex (docs.unoverse.ai/design/apps)`);
|
|
913
876
|
return sizes;
|
|
914
|
-
} catch {
|
|
915
|
-
|
|
877
|
+
} catch (e) {
|
|
878
|
+
// SAID, NOT SWALLOWED (2026-09-10): a sizes file that will not parse used to return null
|
|
879
|
+
// here, and the org silently inherited the foundation's set, so every width of its own
|
|
880
|
+
// "named no app size" and the real fault, a stray colon in the file, was never named.
|
|
881
|
+
report("error", p, `app-sizes is not valid ${p.endsWith(".json") ? "JSON" : "YAML"}: ${String(e?.message ?? e).split("\n")[0]}. Until it parses, none of this org's own sizes exist`);
|
|
882
|
+
return null;
|
|
916
883
|
}
|
|
917
884
|
}
|
|
918
885
|
function appSizesForFile(file) {
|
|
@@ -975,13 +942,39 @@ function componentNamesForFile(file) {
|
|
|
975
942
|
return names;
|
|
976
943
|
}
|
|
977
944
|
|
|
945
|
+
// THE PLACES APPS DECLARE (UNOVERSE_SCREEN.md §2.2), across the whole tree. A component is shared
|
|
946
|
+
// by every app that shows it, so a state's `place:` is checked against every app's places, plus
|
|
947
|
+
// `chat`. Read once per run: every app layout under any `apps/` folder, every `Place` node in it.
|
|
948
|
+
let placeNamesCache;
|
|
949
|
+
function placeNames() {
|
|
950
|
+
if (placeNamesCache !== undefined) return placeNamesCache;
|
|
951
|
+
const names = new Set();
|
|
952
|
+
const walk = (n) => {
|
|
953
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
954
|
+
if (!n || typeof n !== "object") return;
|
|
955
|
+
if (n.type === "Place" && typeof n.name === "string") names.add(n.name);
|
|
956
|
+
for (const v of Object.values(n)) walk(v);
|
|
957
|
+
};
|
|
958
|
+
const roots = [DS, ...readdirSync(DESIGN_ROOT).filter((e) => !e.startsWith(".")).map((e) => join(DESIGN_ROOT, e))];
|
|
959
|
+
for (const root of new Set(roots))
|
|
960
|
+
for (const f of jsonFiles(join(root, "apps"))) {
|
|
961
|
+
try {
|
|
962
|
+
walk(parseDef(readText(f), f));
|
|
963
|
+
} catch {
|
|
964
|
+
/* an unreadable file is reported by its own lint; it declares no place here */
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
placeNamesCache = names;
|
|
968
|
+
return names;
|
|
969
|
+
}
|
|
970
|
+
|
|
978
971
|
|
|
979
972
|
// The run context: everything the extracted rule modules close over. Built here so it
|
|
980
973
|
// cannot outlive the run, and passed once rather than threaded as ten parameters.
|
|
981
974
|
const ctx = {
|
|
982
975
|
DESIGN_ROOT, DS, orgDirs, report, spaceSteps, stepList, checkDimension, checkToken, checkCondition,
|
|
983
|
-
appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists,
|
|
984
|
-
isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePath, isTemplatePart, defRoot, readText,
|
|
976
|
+
appSizesForFile, componentNamesForFile, placeNames, refResolves, canonicalRef, declaredProps, atomsDirExists,
|
|
977
|
+
isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePath, isTemplatePart, isAgentPath, defRoot, readText,
|
|
985
978
|
};
|
|
986
979
|
const walkNode = makeWalkNode(ctx);
|
|
987
980
|
const lintFile = makeLintFile({ ...ctx, walkNode });
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// ── ground truth (mirrors design/_schema/unoverse.schema.json + server guards) ──
|
|
14
14
|
export const PRIMITIVES = new Set([
|
|
15
15
|
"Box", "Stack", "Row", "Column", "Each", "Switch", "ComponentSlot", "Timeline",
|
|
16
|
-
"Text", "Image", "Button", "Input", "Select", "Markdown", "Skeleton", "Icon", "Ref", "Orb", "Template",
|
|
16
|
+
"Text", "Image", "Button", "Input", "Select", "Markdown", "Skeleton", "Icon", "Ref", "Orb", "Template", "Place",
|
|
17
17
|
]);
|
|
18
18
|
export const CONDITION_KEYS = new Set(["field", "eq", "ne", "in"]);
|
|
19
19
|
|
|
@@ -78,7 +78,7 @@ export const PRIMITIVE_PROPS = {
|
|
|
78
78
|
Column: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
79
79
|
Text: ["value"],
|
|
80
80
|
Image: ["src", "alt"],
|
|
81
|
-
Markdown: ["value", "inheritLinkColor"],
|
|
81
|
+
Markdown: ["value", "inheritLinkColor", "citeAction"],
|
|
82
82
|
Icon: ["icon", "name"],
|
|
83
83
|
Skeleton: ["variant"],
|
|
84
84
|
Orb: ["state", "size"],
|
|
@@ -94,6 +94,9 @@ export const PRIMITIVE_PROPS = {
|
|
|
94
94
|
// template's machinery. `name` addresses the manifest's `templates:` entry;
|
|
95
95
|
// `frame` is the dressing drawn around it when occupied.
|
|
96
96
|
Template: ["name", "frame", "template", "director", "appWidth"],
|
|
97
|
+
// A PLACE (UNOVERSE_SCREEN.md §2.2): a named spot the screen shows interfaces into.
|
|
98
|
+
// `holds` is one or many; `frame` and `fallback` work as they do on a ComponentSlot.
|
|
99
|
+
Place: ["name", "holds", "frame", "fallback", "appWidth"],
|
|
97
100
|
Timeline: ["user", "assistant", "userData", "assistantData", "autoScroll"],
|
|
98
101
|
};
|
|
99
102
|
|
|
@@ -25,10 +25,12 @@ const SEARCH_KEYS = ["component", "type", "include", "pick", "items"];
|
|
|
25
25
|
// The `object_type`s a place may admit. Apps and skills are absent by construction: the
|
|
26
26
|
// page's own searches are SILENT (no app unlocked, no tool minted, no card lane, no
|
|
27
27
|
// director woken), or every place would rain cards onto a page still assembling itself.
|
|
28
|
-
|
|
28
|
+
// `products` since migration 035 (2026-09-05; `services` retired 2026-09-07, `actions` with it:
|
|
29
|
+
// no row is written as one). Each word maps to a declared spatial type (guarded by a test).
|
|
30
|
+
const QUERY_TYPES = ["products", "images", "needs"];
|
|
29
31
|
|
|
30
32
|
export function makeWalkNode(ctx) {
|
|
31
|
-
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, defRoot } = ctx;
|
|
33
|
+
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, isTemplatePart, defRoot } = ctx;
|
|
32
34
|
// One director per LAYOUT FILE, tracked for this run only (each file is walked once).
|
|
33
35
|
const directorsSeen = new Map();
|
|
34
36
|
|
|
@@ -36,6 +38,13 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
36
38
|
if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
|
|
37
39
|
if (!node || typeof node !== "object") return;
|
|
38
40
|
|
|
41
|
+
// THE ✕ BELONGS TO THE TEMPLATE'S LAYOUT, NEVER TO A PART (2026-09-07). `setValue` writes
|
|
42
|
+
// the interface's OWN slice (docs/design/state.md) and a part is its own interface, so a
|
|
43
|
+
// close inside a part retracts the PART into the chat column while the page stays open
|
|
44
|
+
// beside it (observed live: a page's hero, ✕ and all, sitting in the flow).
|
|
45
|
+
if (node.type === "Ref" && node.ref === "close-button" && isTemplatePart(file))
|
|
46
|
+
report("error", file, `a close-button inside a template PART writes the part's own slice, not the template's — the page stays open and the part falls into the chat. Move the ✕ to the template's layout (layouts/<state>.yaml), where its setValue writes the template's state`);
|
|
47
|
+
|
|
39
48
|
// $include — resolves against the DEFINITION ROOT (layouts/ states/ components/…)
|
|
40
49
|
if (typeof node.$include === "string") {
|
|
41
50
|
const a = defPath(root, node.$include);
|
|
@@ -129,8 +138,9 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
129
138
|
report("error", file, `a delivery band has no "${k}". It reads ${DELIVERY_KEYS.map((x) => `"${x}"`).join(", ")} (§9b; a place that names a component is a search place)`);
|
|
130
139
|
if (d.pick !== undefined && (typeof d.pick !== "number" || d.pick < 1))
|
|
131
140
|
report("error", file, `director.pick is a NUMBER — how many the director keeps, a cap the machine enforces (§9e rule 9)`);
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
// NOTHING READS `rules`: the director's model was removed (UNOVERSE_SCREEN.md §2.7).
|
|
142
|
+
if (d.rules !== undefined)
|
|
143
|
+
report("warn", file, `director.rules is ignored: no model reads it any more. A band holds what arrives in search rank, up to pick. Remove it`);
|
|
134
144
|
if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
|
|
135
145
|
report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
|
|
136
146
|
else if (Array.isArray(d.preview)) {
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The linters.
|
|
3
|
-
*
|
|
2
|
+
* The linters. FOUR siblings, one per kind of thing a developer authors, and every kind
|
|
3
|
+
* is checked the same way: `unoverse lint`, the publish gate, CI, all through
|
|
4
|
+
* items/publish lintForPublish.
|
|
4
5
|
*
|
|
5
|
-
* nodes/
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* design/
|
|
9
|
-
*
|
|
6
|
+
* nodes/ node manifests: JSON Schema per part, plus the cross-file rules a schema
|
|
7
|
+
* cannot express (events matching output connectors, calls reachable by name,
|
|
8
|
+
* credentials declared in the package that needs them)
|
|
9
|
+
* design/ definitions: a CLOSED primitive set, a closed style vocabulary, token-only
|
|
10
|
+
* values, Switch discriminants, layouts reachable from a case
|
|
11
|
+
* objects/ objects and pipelines: schema per file, plus the rules a schema cannot say
|
|
12
|
+
* prompts/ Agent skills and prompt blocks: frontmatter that parses, the fields the
|
|
13
|
+
* loaders need, and the discoverability contract on whenToUse
|
|
10
14
|
*
|
|
11
|
-
* They share `context.mjs` and nothing else. One reports on `api/run.yaml`,
|
|
12
|
-
* whether a colour is a raw hex. Merging them would produce a linter that is worse at
|
|
15
|
+
* They share `context.mjs` and nothing else. One reports on `api/run.yaml`, another on
|
|
16
|
+
* whether a colour is a raw hex. Merging them would produce a linter that is worse at all.
|
|
13
17
|
*
|
|
14
18
|
* WHY THEY LIVE HERE rather than in scripts/. The same rules are needed by four callers,
|
|
15
19
|
* and only one of them is a terminal:
|
|
@@ -25,4 +29,6 @@
|
|
|
25
29
|
|
|
26
30
|
export { lintNodes, hasErrors as nodesHaveErrors } from "./nodes/index.mjs";
|
|
27
31
|
export { lintDefinitions, hasErrors as definitionsHaveErrors } from "./design/index.mjs";
|
|
32
|
+
export { lintObjects, hasErrors as objectsHaveErrors } from "./objects/index.mjs";
|
|
33
|
+
export { lintPrompts, hasErrors as promptsHaveErrors } from "./prompts/index.mjs";
|
|
28
34
|
export { problems, report, rel, reset } from "./context.mjs";
|
|
@@ -23,7 +23,7 @@ export const HANDLEBARS_HELPERS = new Set([
|
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* The prompt-block library:
|
|
26
|
+
* The prompt-block library: design/marketplace/blocks/**\/*.md, keyed the way the resolver keys them
|
|
27
27
|
* (engine/src/template/promptBlocks.ts) — the filename camelCased, so markdown-guidelines.md is
|
|
28
28
|
* referenced as {{prompt.markdownGuidelines}}.
|
|
29
29
|
*
|
|
@@ -41,7 +41,11 @@ export const HANDLEBARS_HELPERS = new Set([
|
|
|
41
41
|
const CACHE = new Map();
|
|
42
42
|
|
|
43
43
|
export function promptBlocks() {
|
|
44
|
-
|
|
44
|
+
// The block library is the marketplace tier's. Beside the nodes when they ARE that
|
|
45
|
+
// tier (design/marketplace/{nodes,blocks}); one tier over for an org's own nodes
|
|
46
|
+
// (design/<org>/nodes → design/marketplace/blocks).
|
|
47
|
+
const home = [resolve(state.nodesHome, "../blocks"), resolve(state.nodesHome, "../../marketplace/blocks")]
|
|
48
|
+
.find((d) => existsSync(d)) ?? resolve(state.nodesHome, "../../marketplace/blocks");
|
|
45
49
|
const cached = CACHE.get(home);
|
|
46
50
|
if (cached) return cached;
|
|
47
51
|
|
|
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import { problems, derivedKinds, reset, report } from "../context.mjs";
|
|
26
26
|
import { loadSchemas } from "./schema.mjs";
|
|
27
27
|
import { lintPackage } from "./package.mjs";
|
|
28
|
+
import { lintAttachments } from "./node.mjs";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Lint every node package under `nodesHome`.
|
|
@@ -72,6 +73,10 @@ export function lintNodes(nodesHome) {
|
|
|
72
73
|
nodeCount += lintPackage(dir);
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// AFTER every package: an attachment names no host, so whether it fits one cannot be known
|
|
77
|
+
// until every node's configSchema has been read. See lintAttachments.
|
|
78
|
+
lintAttachments();
|
|
79
|
+
|
|
75
80
|
const rank = { error: 0, warn: 1, hint: 2 };
|
|
76
81
|
const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
|
|
77
82
|
return { problems: sorted, derivedKinds: new Map(derivedKinds), nodeCount, pkgCount, nodesHome: home };
|
|
@@ -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
|
+
}
|