@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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PIPELINES, READ (docs/unoverse/data/pipelines/README.md layer 1, ruled 2026-09-09).
|
|
3
|
+
*
|
|
4
|
+
* A pipeline is the source side of ingestion: how content is collected and which objects it
|
|
5
|
+
* may yield. It never knows how an object is built. Its folder is the node's folder:
|
|
6
|
+
*
|
|
7
|
+
* pipelines/<name>/pipeline.yaml type: pipeline, the face (title, description, whenToUse,
|
|
8
|
+
* category, version), and three things of its own:
|
|
9
|
+
* `collect`, the nodes that fetch, in order, each naming a
|
|
10
|
+
* marketplace node and filling its config; `make`, the reads,
|
|
11
|
+
* in order, one block per read and one model call: what is
|
|
12
|
+
* read (each over a list, partsOf, or each <object> by: folder),
|
|
13
|
+
* its address and content, and the objects that read makes
|
|
14
|
+
* pipelines/<name>/config.yaml the Import form: a configSchema as a node's
|
|
15
|
+
* pipelines/<name>/interface.yaml the pipeline as a node on a canvas: signal in, rows out
|
|
16
|
+
* pipelines/<name>/test.yaml the collect steps faked, the rows asserted
|
|
17
|
+
*
|
|
18
|
+
* in two tiers, the marketplace's (`design/marketplace/pipelines/`) and an org's own
|
|
19
|
+
* (`design/<org>/pipelines/`). Nodes are the only way a pipeline touches the world: no HTTP,
|
|
20
|
+
* no credentials, no hosts in the file; the nodes it names carry them.
|
|
21
|
+
*
|
|
22
|
+
* This module only reads. Studio authors, the platform reads.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { readDefCached, defPath } from "../fsCache.js";
|
|
27
|
+
import { composeIncludes } from "../load/expand/includes.js";
|
|
28
|
+
import { pipelineDirs } from "./homes.js";
|
|
29
|
+
import { pipelineFindings, firstRefusal, PIPELINE_READ_KEYS } from "../../lint/objects/rules.mjs";
|
|
30
|
+
|
|
31
|
+
export interface CollectStep {
|
|
32
|
+
/** The step's name; a later step reads its outputs as `{{ collect.<name>.<output> }}`. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** The marketplace node it runs. */
|
|
35
|
+
node: string;
|
|
36
|
+
/** The node's config, with `{{ config.* }}` and `{{ collect.* }}` references. */
|
|
37
|
+
config: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ONE OBJECT A READ MAKES (docs/unoverse/data/pipelines/README.md, "A pipeline, in one screen",
|
|
42
|
+
* 2026-09-14). The file says `make` as a list of reads, each naming the objects it makes; the
|
|
43
|
+
* reader hands the engine one entry per object, carrying its read's words: what is read
|
|
44
|
+
* (`each`: always one, `any`: maybe, the model says), the address and content only the source
|
|
45
|
+
* can say, and the object's count. Objects of one read share its list and address, so the
|
|
46
|
+
* engine reads each unit once for all of them.
|
|
47
|
+
*/
|
|
48
|
+
export interface MakeEntry {
|
|
49
|
+
object: string;
|
|
50
|
+
/** A list reference/expression over collect, or the name of an object made above. */
|
|
51
|
+
each?: string;
|
|
52
|
+
/** A list reference/expression over collect: maybe one per unit; the model says. */
|
|
53
|
+
any?: string;
|
|
54
|
+
/**
|
|
55
|
+
* A PART (ruled 2026-09-11, docs/unoverse/data/pipelines/README.md layer 1, parts): the object
|
|
56
|
+
* made above these are part of. One row per item of `each`, a list over `detail` (what that
|
|
57
|
+
* object's make function returned for the read), partOf that row, off the map, no model call.
|
|
58
|
+
*/
|
|
59
|
+
partsOf?: string;
|
|
60
|
+
/**
|
|
61
|
+
* WHICH READS MAY MAKE IT (docs/unoverse/data/pipelines/a-document-decides.md, 2026-09-20), an
|
|
62
|
+
* expression over the read (`row`). The file says it as `where`, a list of allowed values per
|
|
63
|
+
* field. It is asked of what the DOCUMENT'S OWN READ said, so a document decides which objects
|
|
64
|
+
* it makes: a monthly risk report says the fund it is about, a DDQ says the firm. Absent: every
|
|
65
|
+
* read of this entry makes it.
|
|
66
|
+
*/
|
|
67
|
+
when?: string;
|
|
68
|
+
/** How many of this object one source makes (owner, 2026-09-13): one, or many. The read asks by it; a part's entry says none. */
|
|
69
|
+
cardinality?: "one" | "many";
|
|
70
|
+
sourceUrl?: string;
|
|
71
|
+
sourceId?: string;
|
|
72
|
+
/** The item's content, each value a reference over `item`, `index`, `config`, `collect`. */
|
|
73
|
+
content: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
export interface PipelineDefinition {
|
|
78
|
+
name: string;
|
|
79
|
+
/** The org that owns it; undefined for a marketplace pipeline. */
|
|
80
|
+
org?: string;
|
|
81
|
+
/** The face, the words a node's node.yaml uses: title, description, whenToUse, category, version, color, logoUrl. */
|
|
82
|
+
manifest: { title?: string; description?: string; whenToUse?: string; category?: string; version?: string; color?: string; logoUrl?: string };
|
|
83
|
+
collect: CollectStep[];
|
|
84
|
+
/** What this pipeline makes, in order. */
|
|
85
|
+
make: MakeEntry[];
|
|
86
|
+
/** The objects this pipeline makes, by name, in order: derived from `make`, for the screens. */
|
|
87
|
+
yields: string[];
|
|
88
|
+
/** The Import form, as a node's configSchema; empty when the folder has no config.yaml. */
|
|
89
|
+
configSchema: Record<string, unknown>;
|
|
90
|
+
/** The pipeline as a node: inputs, outputs, credentials. */
|
|
91
|
+
interface: Record<string, unknown>;
|
|
92
|
+
/** The folder on disk. */
|
|
93
|
+
dir: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// NO RESULT MAP, as for objects: the file reader's mtime check is the cache, so an edited
|
|
97
|
+
// or published folder is seen on the next read in every process (2026-09-09: the engine
|
|
98
|
+
// kept the old website pipeline until a restart).
|
|
99
|
+
|
|
100
|
+
function folderOf(name: string, org?: string | null): { dir: string; org?: string } | null {
|
|
101
|
+
for (const { org: o, dir } of pipelineDirs(org ?? null)) {
|
|
102
|
+
const folder = join(dir, name);
|
|
103
|
+
if (existsSync(folder) && statSync(folder).isDirectory() && defPath(folder, "pipeline")) return { dir: folder, org: o };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
109
|
+
return !!x && typeof x === "object" && !Array.isArray(x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `where` IS A LIST OF ALLOWED VALUES PER FIELD, read as the check the engine runs over one read.
|
|
114
|
+
* A document decides which objects it makes: the check is asked of what its own read said, never
|
|
115
|
+
* of the folder it was found in (docs/unoverse/data/pipelines/a-document-decides.md).
|
|
116
|
+
*/
|
|
117
|
+
function whenOf(spec: Record<string, any>): string | undefined {
|
|
118
|
+
const where = isRecord(spec.where) ? spec.where : {};
|
|
119
|
+
if (!Object.keys(where).length) return undefined;
|
|
120
|
+
const each = Object.entries(where).map(([field, values]) => `${JSON.stringify(Array.isArray(values) ? values : [values])}.includes(row[${JSON.stringify(field)}])`);
|
|
121
|
+
return `return ${each.join(" && ")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readFolder(dir: string, name: string, org: string | undefined): PipelineDefinition {
|
|
125
|
+
// ITS SIBLINGS, INLINED (`$include`, as an object's folder composes its props): a pipeline
|
|
126
|
+
// that knows many kinds of document keeps one file per kind beside it rather than one long
|
|
127
|
+
// block, and a kind is added by adding a file. Resolved against the pipeline's own folder.
|
|
128
|
+
const file = (composeIncludes(readDefCached<Record<string, any>>(defPath(dir, "pipeline")!) ?? {}, dir) ?? {}) as Record<string, any>;
|
|
129
|
+
// THE RULES ARE THE LINTER'S (lint/objects/rules.mjs pipelineFindings): refused here with
|
|
130
|
+
// the same sentence the linter prints, one spelling of every rule (objects README 9a).
|
|
131
|
+
const refused = firstRefusal(pipelineFindings(name, file));
|
|
132
|
+
if (refused) throw new Error(`${refused} (${dir})`);
|
|
133
|
+
const collect: CollectStep[] = (file.collect as any[]).map((s: any) => ({ name: s.name, node: s.node, config: isRecord(s.config) ? s.config : {} }));
|
|
134
|
+
// THE FILE'S SHAPE, READ INTO THE ENTRIES THE ENGINE RUNS (docs/unoverse/data/pipelines/README.md,
|
|
135
|
+
// `make`, 2026-09-15; a-document-decides.md, 2026-09-20). Every entry reads a list: one item per
|
|
136
|
+
// unit, yielding the objects named under it. An object's `parts` are rows taken from its read;
|
|
137
|
+
// its `where` says which reads make it, asked of what the read said. A row's address and identity
|
|
138
|
+
// are the item's own unless the entry says them; what it carries is the item as listed unless the
|
|
139
|
+
// entry says `content`.
|
|
140
|
+
const make: MakeEntry[] = [];
|
|
141
|
+
for (const raw of file.make as Record<string, any>[]) {
|
|
142
|
+
const objects = Object.keys(raw).filter((k) => !PIPELINE_READ_KEYS.has(k));
|
|
143
|
+
const list = String(raw.each ?? raw.any ?? "").trim();
|
|
144
|
+
for (const object of objects) {
|
|
145
|
+
const spec = isRecord(raw[object]) ? raw[object] : {};
|
|
146
|
+
make.push({
|
|
147
|
+
object,
|
|
148
|
+
...(spec.any === true ? { any: list } : { each: list }),
|
|
149
|
+
...(spec.cardinality === "one" || spec.cardinality === "many" ? { cardinality: spec.cardinality as "one" | "many" } : {}),
|
|
150
|
+
...(whenOf(spec) ? { when: whenOf(spec) } : {}),
|
|
151
|
+
sourceUrl: typeof raw.sourceUrl === "string" ? raw.sourceUrl : "{{ item.address }}",
|
|
152
|
+
sourceId: typeof raw.sourceId === "string" ? raw.sourceId : "{{ item.universalId }}",
|
|
153
|
+
content: isRecord(raw.content) ? raw.content : {},
|
|
154
|
+
});
|
|
155
|
+
for (const [part, from] of Object.entries(isRecord(spec.parts) ? spec.parts : {})) make.push({ object: part, partsOf: object, each: String(from).trim(), content: {} });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const configPath = defPath(dir, "config");
|
|
159
|
+
const config = configPath ? (readDefCached<Record<string, any>>(configPath) ?? {}) : {};
|
|
160
|
+
const interfacePath = defPath(dir, "interface");
|
|
161
|
+
const iface = interfacePath ? (readDefCached<Record<string, any>>(interfacePath) ?? {}) : {};
|
|
162
|
+
return {
|
|
163
|
+
name,
|
|
164
|
+
...(org ? { org } : {}),
|
|
165
|
+
manifest: { title: file.title, description: file.description, whenToUse: file.whenToUse, category: file.category, version: file.version, color: file.color, logoUrl: file.logoUrl },
|
|
166
|
+
collect,
|
|
167
|
+
make,
|
|
168
|
+
yields: make.map((m) => m.object),
|
|
169
|
+
configSchema: isRecord(config.configSchema) ? { ...config.configSchema, ...(config["ui:order"] ? { "ui:order": config["ui:order"] } : {}) } : {},
|
|
170
|
+
interface: iface,
|
|
171
|
+
dir,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One pipeline by name, the org's tier first then the marketplace. Null when no tier holds it. */
|
|
176
|
+
export function readPipeline(name: string, org?: string | null): PipelineDefinition | null {
|
|
177
|
+
const hit = folderOf(name, org);
|
|
178
|
+
return hit ? readFolder(hit.dir, name, hit.org) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every pipeline an org can see: the marketplace's and its own. Bare (no org) lists the marketplace's plus every org's. */
|
|
182
|
+
export function listPipelines(org?: string | null): PipelineDefinition[] {
|
|
183
|
+
const out: PipelineDefinition[] = [];
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
for (const { org: o, dir } of pipelineDirs(org ?? null)) {
|
|
186
|
+
if (!existsSync(dir)) continue;
|
|
187
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
188
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
189
|
+
const key = `${o ?? ""}/${e.name}`;
|
|
190
|
+
if (seen.has(key)) continue;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
const def = readPipeline(e.name, o ?? null);
|
|
193
|
+
if (def) out.push(def);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
@@ -36,7 +36,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
36
36
|
// VALUES are each org's own — that's how two clients get different fonts/colors/spacing
|
|
37
37
|
// from the same components, without re-copying the foundation.
|
|
38
38
|
import { listOrgs, projectDir } from "./definitions.js";
|
|
39
|
-
import {
|
|
39
|
+
import { PLUGINS_DIR, DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir } from "../paths.js";
|
|
40
40
|
|
|
41
41
|
const DESIGN = DESIGN_HOME;
|
|
42
42
|
export const DEFAULT_ORG = "default";
|
|
@@ -58,7 +58,6 @@ function orgStyles(org: string): string | null {
|
|
|
58
58
|
* the package (no `rx/marketplace` on disk) once purged from the image (Phase 4).
|
|
59
59
|
*/
|
|
60
60
|
const DS_STYLES_CANDIDATES = [
|
|
61
|
-
join(NODES_HOME, "marketplace", "definitions", "styles"),
|
|
62
61
|
join(PLUGINS_DIR, "node_modules", "@unoverse-platform", "marketplace", "definitions", "styles"),
|
|
63
62
|
];
|
|
64
63
|
/**
|
|
@@ -48,12 +48,8 @@ const isDef = (f: string) => f.endsWith(".yaml") || f.endsWith(".json");
|
|
|
48
48
|
function designSystemDir(designRoot: string): string {
|
|
49
49
|
const onDisk = join(designRoot, "marketplace");
|
|
50
50
|
if (existsSync(onDisk)) return onDisk;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
join(nodesHome, "marketplace", "definitions"),
|
|
54
|
-
join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions"),
|
|
55
|
-
])
|
|
56
|
-
if (existsSync(c)) return c;
|
|
51
|
+
const bundled = join(designRoot, "..", "plugins", "node_modules", "@unoverse-platform", "marketplace", "definitions");
|
|
52
|
+
if (existsSync(bundled)) return bundled;
|
|
57
53
|
return onDisk; // nothing found: the version will hash to an empty set, which is honest
|
|
58
54
|
}
|
|
59
55
|
|
|
@@ -108,7 +104,12 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
108
104
|
// A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
|
|
109
105
|
// Apps keep their bare ids: those are org-qualified by convention
|
|
110
106
|
// (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
|
|
111
|
-
|
|
107
|
+
// `identity` (2026-09-05): the org's four documents, `<org>/brand` like a component.
|
|
108
|
+
// `agent` (2026-09-06): a workflow listed for the map, design/<org>/agents/<name>.yaml, `<org>/<name>`.
|
|
109
|
+
// `object` and `pipeline` (2026-09-09): an org's own kinds beside the marketplace masters
|
|
110
|
+
// (docs/unoverse/data/objects/README.md 4e, docs/unoverse/data/pipelines/README.md layer 1), `<org>/<name>` like a
|
|
111
|
+
// component, since two orgs may each write a `deal`.
|
|
112
|
+
const QUALIFIED_KINDS = new Set(["component", "skill", "template", "identity", "agent", "object", "pipeline"]);
|
|
112
113
|
const add = (kind: string, name: string, definition: CollectedItem["definition"]) =>
|
|
113
114
|
items.push({
|
|
114
115
|
kind,
|
|
@@ -134,7 +135,16 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
134
135
|
// 029): apps publish as kind `app` and the Template Model kind owns the bare
|
|
135
136
|
// word. Forgetting a design dir here ships deploys that silently drop every
|
|
136
137
|
// artifact of that kind.
|
|
137
|
-
|
|
138
|
+
// IDENTITY (owner ruling 2026-09-05): design/<org>/identity/<doc>/ publishes as kind
|
|
139
|
+
// `identity`, the same walk as a component folder, so it reaches the registry, the
|
|
140
|
+
// spatial catalogue and the map through the one publish path everything else takes.
|
|
141
|
+
// AGENT (owner ruling 2026-09-06): design/<org>/agents/<name>.yaml, a single file like an atom,
|
|
142
|
+
// publishes as kind `agent` so it reaches the registry, the catalogue and the map.
|
|
143
|
+
// OBJECT and PIPELINE (owner ruling 2026-09-09): design/<org>/objects/<name>/ and
|
|
144
|
+
// design/<org>/pipelines/<name>/, folders like a component, publish as their own kinds so
|
|
145
|
+
// an org's custom object and its own source reach the engine that positions its rows and
|
|
146
|
+
// the inbox that collects for it. The marketplace's own go through designSystemItems().
|
|
147
|
+
for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"], ["identity", "identity"], ["agents", "agent"], ["objects", "object"], ["pipelines", "pipeline"]] as const) {
|
|
138
148
|
const home = join(root, dir);
|
|
139
149
|
if (!existsSync(home)) continue;
|
|
140
150
|
for (const e of readdirSync(home, { withFileTypes: true })) {
|
|
@@ -198,22 +208,20 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
198
208
|
walk(blocksHome);
|
|
199
209
|
}
|
|
200
210
|
|
|
201
|
-
// nodes/ — THE
|
|
202
|
-
// marketplace stores a node (core/items/catalogue.ts) so
|
|
203
|
-
// on the same path as disk: the raw files, plus the
|
|
204
|
-
// credential shapes resolve against. The name is the
|
|
205
|
-
// workflow stores that identity and nothing else.
|
|
211
|
+
// nodes/ — THE PROJECT'S NODE PACKAGES, design/<org>/nodes/<package>/, one row per
|
|
212
|
+
// node, shaped exactly as the marketplace stores a node (core/items/catalogue.ts) so
|
|
213
|
+
// the row source composes them on the same path as disk: the raw files, plus the
|
|
214
|
+
// package envelope their $refs and credential shapes resolve against. The name is the
|
|
215
|
+
// node's `type`, bare: a saved workflow stores that identity and nothing else.
|
|
206
216
|
//
|
|
207
|
-
// Until 2026-09-05 deploy never looked
|
|
208
|
-
// components and skills and silently left every node behind.
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const nodesHome = join(designRoot, "..", "nodes");
|
|
216
|
-
if (existsSync(nodesHome) && !existsSync(join(designRoot, "marketplace"))) {
|
|
217
|
+
// Until 2026-09-05 deploy never looked at nodes at all, so `deploy studio` shipped a
|
|
218
|
+
// developer's components and skills and silently left every node behind. Until
|
|
219
|
+
// 2026-09-08 it looked at a `nodes/` folder beside design/ while every org already
|
|
220
|
+
// kept its nodes INSIDE its project, so the same nodes were left behind again. Org
|
|
221
|
+
// nodes are org-scoped like skills; the marketplace tier's own nodes sit under
|
|
222
|
+
// design/marketplace/, which is never collected.
|
|
223
|
+
const nodesHome = join(root, "nodes");
|
|
224
|
+
if (existsSync(nodesHome)) {
|
|
217
225
|
for (const pkg of collectNodePackages(nodesHome)) {
|
|
218
226
|
for (const raw of pkg.nodes) {
|
|
219
227
|
const type = nodeType(raw.files["node.yaml"]);
|
|
@@ -230,9 +238,9 @@ export function collectProject(designRoot: string, project: string): CollectedIt
|
|
|
230
238
|
return items;
|
|
231
239
|
}
|
|
232
240
|
|
|
233
|
-
/** The
|
|
241
|
+
/** The project's node packages, read the way the platform reads its own. */
|
|
234
242
|
function collectNodePackages(nodesHome: string) {
|
|
235
|
-
return readDiskPackages(nodesHome)
|
|
243
|
+
return readDiskPackages(nodesHome);
|
|
236
244
|
}
|
|
237
245
|
|
|
238
246
|
/** `type:` off a node.yaml: the one scalar the row is named by. Unparseable means none. */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* new, changed, unchanged, or refused because the name belongs to someone else. Publishing
|
|
11
11
|
* blind is how a project half-lands and nobody can tell by looking.
|
|
12
12
|
*
|
|
13
|
-
* See docs/architecture/DECLARATIVE_NODES.md §9.
|
|
13
|
+
* See docs/architecture/authoring/DECLARATIVE_NODES.md §9.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
@@ -49,17 +49,41 @@ export async function lintForPublish(designRoot: string, project: string): Promi
|
|
|
49
49
|
const { lintDefinitions } = await import("../lint/design/index.mjs");
|
|
50
50
|
const result = lintDefinitions(designRoot);
|
|
51
51
|
/**
|
|
52
|
-
* NODES TOO. A
|
|
53
|
-
* Studio Nodes screen reads), and until 2026-09-05 this gate never looked
|
|
54
|
-
* node with an events row out of order or a host missing from
|
|
55
|
-
* cleanly as a correct one. The node linter is the same
|
|
56
|
-
* runs. A
|
|
52
|
+
* NODES TOO. A project keeps its node packages at design/<project>/nodes/ (the same
|
|
53
|
+
* folder the Studio Nodes screen reads), and until 2026-09-05 this gate never looked
|
|
54
|
+
* at nodes at all: a node with an events row out of order or a host missing from
|
|
55
|
+
* allowedHosts deployed as cleanly as a correct one. The node linter is the same
|
|
56
|
+
* library the platform's own CI runs. A project with no nodes/ folder has nothing to
|
|
57
|
+
* lint and is not an error.
|
|
57
58
|
*/
|
|
58
|
-
const nodesHome = join(designRoot,
|
|
59
|
+
const nodesHome = join(designRoot, project, "nodes");
|
|
59
60
|
if (existsSync(nodesHome)) {
|
|
60
61
|
const { lintNodes } = await import("../lint/nodes/index.mjs");
|
|
61
62
|
result.problems.push(...lintNodes(nodesHome).problems);
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* OBJECTS AND PIPELINES TOO (objects README 9a, 2026-09-10). They live under
|
|
66
|
+
* design/<tier>/objects and /pipelines, folders neither linter above enumerates, and a
|
|
67
|
+
* home not enumerated is a home where every rule is off, silently. The objects linter
|
|
68
|
+
* walks every tier of the design root, like the design linter; the project filter below
|
|
69
|
+
* keeps this project's findings.
|
|
70
|
+
*/
|
|
71
|
+
{
|
|
72
|
+
const { lintObjects } = await import("../lint/objects/index.mjs");
|
|
73
|
+
result.problems.push(...lintObjects(designRoot).problems);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* SKILLS AND PROMPT BLOCKS TOO (2026-09-18). They live under design/<tier>/skills and
|
|
77
|
+
* /blocks, and nothing read them before publish: a SKILL.md whose frontmatter did not
|
|
78
|
+
* parse (a bare colon in the description) was published, and the run-time loaders then
|
|
79
|
+
* dropped it with one console line, so it was absent from every list and nobody was
|
|
80
|
+
* told. The prompts linter walks every tier, like the objects linter; the project filter
|
|
81
|
+
* below keeps this project's findings.
|
|
82
|
+
*/
|
|
83
|
+
{
|
|
84
|
+
const { lintPrompts } = await import("../lint/prompts/index.mjs");
|
|
85
|
+
result.problems.push(...lintPrompts(designRoot).problems);
|
|
86
|
+
}
|
|
63
87
|
/**
|
|
64
88
|
* SCOPED TO THE PROJECT BEING DEPLOYED, on paths that may be relative. The old
|
|
65
89
|
* check looked for "/design/" with a leading slash, which a relative path never
|
|
@@ -20,6 +20,21 @@ export const derivedKinds = new Map();
|
|
|
20
20
|
/** Credential name -> where it was declared, for cross-package collision rules. */
|
|
21
21
|
export const allCredentials = new Map();
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* ATTACHMENTS, and the config fields every other node declares.
|
|
25
|
+
*
|
|
26
|
+
* An attachment clips onto a node whose configSchema has every field it sets, so "can this
|
|
27
|
+
* attachment ever clip onto anything?" cannot be answered while reading the attachment: its
|
|
28
|
+
* host may be in a package that has not been read yet, or in no package at all. Both halves
|
|
29
|
+
* are collected here and checked once at the end of the run (node.mjs lintAttachments).
|
|
30
|
+
*
|
|
31
|
+
* The alternative was making the author declare the host, which is the `accepts:` list the
|
|
32
|
+
* whole design exists to avoid.
|
|
33
|
+
*/
|
|
34
|
+
export const attachments = new Map();
|
|
35
|
+
/** Node label -> the set of config field names it declares. */
|
|
36
|
+
export const configFields = new Map();
|
|
37
|
+
|
|
23
38
|
/** Where this run is reading. Set by lintNodes(), read by rel() and the loaders. */
|
|
24
39
|
export const state = { nodesHome: "", schemaDir: "", schemas: {} };
|
|
25
40
|
|
|
@@ -29,6 +44,8 @@ export function reset(nodesHome, schemaDir) {
|
|
|
29
44
|
refCounts.clear();
|
|
30
45
|
derivedKinds.clear();
|
|
31
46
|
allCredentials.clear();
|
|
47
|
+
attachments.clear();
|
|
48
|
+
configFields.clear();
|
|
32
49
|
state.nodesHome = nodesHome;
|
|
33
50
|
state.schemaDir = schemaDir;
|
|
34
51
|
state.schemas = {};
|
|
@@ -85,8 +85,44 @@ function checkDiscoveryMeta(report, file, json, kind, docs) {
|
|
|
85
85
|
report("warn", file, `discovery manifest has no "category". Name the domain of the job, never the implementation (${docs})`);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* AN AGENT FILE (docs.unoverse.ai/design/tasks, 2026-09-06): a workflow with no interface,
|
|
90
|
+
* listed for the map. The discovery meta answers to the same rules as every manifest,
|
|
91
|
+
* because the Agent is ranked against what a PERSON said. The door is three fields, always:
|
|
92
|
+
* a canvas, its Input Trigger, and the node whose output is the result; a door missing at
|
|
93
|
+
* run time fails the customer, not the author. The face is optional and defaults to the
|
|
94
|
+
* shared agent-card; when named, it must be a component that resolves.
|
|
95
|
+
*/
|
|
96
|
+
function checkAgentFile(report, file, json, componentNames) {
|
|
97
|
+
const docs = "docs.unoverse.ai/design/tasks";
|
|
98
|
+
if (json.type !== "agent") report("error", file, `agent file must declare "type: agent" (${docs})`);
|
|
99
|
+
const key = basename(file).replace(/\.(json|ya?ml)$/, "");
|
|
100
|
+
if (typeof json.name !== "string" || !json.name.trim()) report("error", file, `agent file missing "name" (${docs})`);
|
|
101
|
+
else {
|
|
102
|
+
if (json.name !== key) report("error", file, `agent "name: ${json.name}" must equal the file name "${key}" (${docs})`);
|
|
103
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(json.name)) report("error", file, `agent name "${json.name}" must be kebab-case: it is the tool's name on the wire (${docs})`);
|
|
104
|
+
}
|
|
105
|
+
checkDiscoveryMeta(report, file, json, "agent", docs);
|
|
106
|
+
const door = ["workflow", "trigger", "output"].filter((k) => typeof json[k] === "string" && json[k].trim());
|
|
107
|
+
if (door.length < 3)
|
|
108
|
+
report("error", file, `agent door is three fields, always: workflow, trigger and output. Present: ${door.join(", ") || "none"} (${docs})`);
|
|
109
|
+
const app = typeof json.app === "string" ? json.app.trim() : "";
|
|
110
|
+
if (app) {
|
|
111
|
+
if (!/^unoverse:\/\/components\//.test(app))
|
|
112
|
+
report("error", file, `agent "app: ${app}" must be a component URI: the face while it runs (${docs})`);
|
|
113
|
+
else if (componentNames && componentNames.size && !componentNames.has(app.split("/").pop().toLowerCase()))
|
|
114
|
+
report("error", file, `agent face "${app}" does not resolve: no such component in this project or the design system (${docs})`);
|
|
115
|
+
}
|
|
116
|
+
if (json.instructions !== undefined && typeof json.instructions !== "string")
|
|
117
|
+
report("error", file, `agent "instructions" must be a string: what to do (${docs})`);
|
|
118
|
+
if (json.skills !== undefined && !(Array.isArray(json.skills) && json.skills.every((x) => typeof x === "string" && x.trim())))
|
|
119
|
+
report("error", file, `agent "skills" must be a list of skill names (${docs})`);
|
|
120
|
+
if (json.message !== undefined && typeof json.message !== "string")
|
|
121
|
+
report("error", file, `agent "message" must be a string: what the face says while it runs (${docs})`);
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
export function makeLintFile(ctx) {
|
|
89
|
-
const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
|
|
125
|
+
const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, isAgentPath, defRoot, componentNamesForFile, placeNames, DS, orgDirs, RX, readText } = ctx;
|
|
90
126
|
|
|
91
127
|
function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
92
128
|
if (!Array.isArray(order)) return;
|
|
@@ -115,6 +151,39 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
|
115
151
|
// one folder grammar — while a manifest-held tree remains the legacy read, so
|
|
116
152
|
// both call sites validate identically).
|
|
117
153
|
function checkAppTree(json, root, file) {
|
|
154
|
+
// PREVIEW NAMES RESOLVE (docs.unoverse.ai/design/apps, ruled 2026-09-10). Studio seeds a
|
|
155
|
+
// picked state from its `preview` list by looking each name up among the components and
|
|
156
|
+
// templates the org can see, by bare name, and a name it cannot find seeds NOTHING and says
|
|
157
|
+
// nothing: the state's tab looks dead and nobody is told why (observed live: an app said
|
|
158
|
+
// `alpha/deal` where the component is `deal`). So the name is checked here, where
|
|
159
|
+
// the file is written, against the same set: the marketplace's and this org's own.
|
|
160
|
+
{
|
|
161
|
+
const comps = componentNamesForFile ? componentNamesForFile(file) : null;
|
|
162
|
+
const walkStates = (states, path) => {
|
|
163
|
+
if (!states || typeof states !== "object" || Array.isArray(states)) return;
|
|
164
|
+
for (const [name, entry] of Object.entries(states)) {
|
|
165
|
+
if (!entry || typeof entry !== "object") continue;
|
|
166
|
+
const where = path ? `${path}.${name}` : name;
|
|
167
|
+
if (Array.isArray(entry.preview)) {
|
|
168
|
+
for (const item of entry.preview) {
|
|
169
|
+
const raw = typeof item === "string" ? item : item && typeof item === "object" && !Array.isArray(item) ? Object.keys(item)[0] : undefined;
|
|
170
|
+
if (typeof raw !== "string" || !raw) continue;
|
|
171
|
+
// THE HASH NAMES THE STATE IT OPENS IN (STATE_MODEL rule 3, ruled 2026-09-09; owner
|
|
172
|
+
// 2026-09-11: a preview is written `deal#grid`). The name is what is checked; the
|
|
173
|
+
// state after the hash is the component's own to declare.
|
|
174
|
+
const ref = raw.split("#")[0];
|
|
175
|
+
if (ref.includes("/"))
|
|
176
|
+
report("error", file, `state "${where}" preview names "${ref}" with an org prefix. Studio seeds a preview by the component's bare name ("${ref.split("/").pop()}"); a prefixed name resolves to nothing and the state's tab shows nothing (docs.unoverse.ai/design/apps)`);
|
|
177
|
+
else if (comps && !comps.has(ref.toLowerCase()))
|
|
178
|
+
report("error", file, `state "${where}" preview names "${ref}", which is no component or template this org can see (design/marketplace/, this org's components/ and templates/). Studio would seed nothing and the state's tab would show nothing (docs.unoverse.ai/design/apps)`);
|
|
179
|
+
}
|
|
180
|
+
} else if (entry.preview !== undefined && (typeof entry.preview !== "object" || entry.preview === null))
|
|
181
|
+
report("error", file, `state "${where}" preview must be a list of component names, or an object of app-state mock data (docs.unoverse.ai/design/apps)`);
|
|
182
|
+
walkStates(entry.states, where);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
walkStates(json?.states, "");
|
|
186
|
+
}
|
|
118
187
|
if (json.states !== undefined) {
|
|
119
188
|
if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
|
|
120
189
|
report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
|
|
@@ -190,6 +259,94 @@ function checkAppTree(json, root, file) {
|
|
|
190
259
|
} else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
|
|
191
260
|
}
|
|
192
261
|
|
|
262
|
+
// ── an app's places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2) ──
|
|
263
|
+
// An app that declares places is drawn from the screen document, which replaces a task with the
|
|
264
|
+
// next one and steps aside what a turn did not touch. An app that declares none runs the old
|
|
265
|
+
// render pipeline, where nothing replaces anything: a voice call stacked three tasks, and a
|
|
266
|
+
// comparison it built never reached the screen (2026-09-12). So the lint says which an app is,
|
|
267
|
+
// and holds a places app to the places its org's interfaces name.
|
|
268
|
+
function checkAppPlaces(json, root, file) {
|
|
269
|
+
const files = [];
|
|
270
|
+
const collect = (dir) => {
|
|
271
|
+
if (!existsSync(dir)) return;
|
|
272
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
273
|
+
const f = join(dir, e.name);
|
|
274
|
+
if (e.isDirectory()) collect(f);
|
|
275
|
+
else if (isDefFile(e.name)) files.push(f);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
collect(join(root, "layouts"));
|
|
279
|
+
collect(join(root, "components"));
|
|
280
|
+
const places = new Set();
|
|
281
|
+
const claims = [];
|
|
282
|
+
const walk = (n, f) => {
|
|
283
|
+
if (Array.isArray(n)) return n.forEach((c) => walk(c, f));
|
|
284
|
+
if (!n || typeof n !== "object") return;
|
|
285
|
+
if (n.type === "Place" && typeof n.name === "string") places.add(n.name);
|
|
286
|
+
if (n.type === "ComponentSlot" && n.select?.where?.field === "state") claims.push(relative(root, f));
|
|
287
|
+
for (const v of Object.values(n)) walk(v, f);
|
|
288
|
+
};
|
|
289
|
+
for (const f of files) {
|
|
290
|
+
try {
|
|
291
|
+
walk(parseDef(readFileSync(f, "utf8"), f), f);
|
|
292
|
+
} catch {
|
|
293
|
+
/* an unreadable file is reported by its own lint */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// THE MANIFEST'S `layout:` NAMES THE BASE STATE. Pointing it at a state the app no longer has
|
|
297
|
+
// leaves the app with no drawing at all: it loads, and nothing is placed (2026-09-12).
|
|
298
|
+
const manifest = defPath(root, "manifest");
|
|
299
|
+
if (manifest) {
|
|
300
|
+
try {
|
|
301
|
+
const m = parseDef(readFileSync(manifest, "utf8"), manifest);
|
|
302
|
+
const names = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? Object.keys(json.states) : [];
|
|
303
|
+
if (m && typeof m.layout === "string" && names.length && !names.includes(m.layout))
|
|
304
|
+
report("error", file, `manifest.yaml says layout: ${m.layout}, which is no state of this app (${names.join(", ")}). The app would load with no drawing. Delete the line: the first state is the base (docs.unoverse.ai/design/apps)`);
|
|
305
|
+
} catch {
|
|
306
|
+
/* reported by its own lint */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!places.size) {
|
|
310
|
+
report("warn", file, `app declares no Place, so it runs the old render pipeline: nothing replaces an open task and nothing steps aside when the conversation moves on. Declare its places in the layout, as sab-chat does (UNOVERSE_SCREEN.md §2.2)`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const states = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? Object.keys(json.states) : [];
|
|
314
|
+
if (states.length > 1)
|
|
315
|
+
report("error", file, `app declares places and ${states.length} states (${states.join(", ")}). An app with places has one arrangement: its places open and close as the screen fills them, so a state per surface is the old ladder and draws nothing (UNOVERSE_SCREEN.md §2.2)`);
|
|
316
|
+
for (const f of new Set(claims))
|
|
317
|
+
report("error", file, `${f} claims interfaces by state (a ComponentSlot with select.where on state), the old reaction surface. In an app with places the screen decides what is shown; draw it in a Place (UNOVERSE_SCREEN.md §2.2)`);
|
|
318
|
+
// THE PLACES THIS ORG'S INTERFACES NAME. An interface whose state names a place this app lacks
|
|
319
|
+
// lands in the conversation, which a voice app does not draw at all.
|
|
320
|
+
const org = basename(dirname(root)) === "apps" ? dirname(dirname(root)) : null;
|
|
321
|
+
if (!org) return;
|
|
322
|
+
const named = new Map();
|
|
323
|
+
const statesOf = (st, where) => {
|
|
324
|
+
if (!st || typeof st !== "object" || Array.isArray(st)) return;
|
|
325
|
+
for (const [n, e] of Object.entries(st)) {
|
|
326
|
+
if (!e || typeof e !== "object") continue;
|
|
327
|
+
if (typeof e.place === "string" && e.place !== "chat" && !named.has(e.place)) named.set(e.place, `${where} (state "${n}")`);
|
|
328
|
+
statesOf(e.states, where);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
for (const kind of ["components", "templates"]) {
|
|
332
|
+
const dir = join(org, kind);
|
|
333
|
+
if (!existsSync(dir)) continue;
|
|
334
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
335
|
+
if (!e.isDirectory()) continue;
|
|
336
|
+
const f = defPath(join(dir, e.name), e.name);
|
|
337
|
+
if (!f) continue;
|
|
338
|
+
try {
|
|
339
|
+
statesOf(parseDef(readFileSync(f, "utf8"), f)?.states, `${kind}/${e.name}`);
|
|
340
|
+
} catch {
|
|
341
|
+
/* reported by its own lint */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const [place, by] of named)
|
|
346
|
+
if (!places.has(place))
|
|
347
|
+
report("error", file, `${by} is shown in place "${place}", which this app does not declare, so here it would land in the conversation. Declare a Place named "${place}" (this app has: ${[...places].sort().join(", ")}) (UNOVERSE_SCREEN.md §2.3)`);
|
|
348
|
+
}
|
|
349
|
+
|
|
193
350
|
// ── lint one file ──
|
|
194
351
|
function lintFile(file) {
|
|
195
352
|
// `readText` is disk unless the caller supplied an overlay for this path (index.mjs).
|
|
@@ -200,7 +357,8 @@ function lintFile(file) {
|
|
|
200
357
|
// Exempt: `appWidth` — the HOST-facing outer width (state-owned sizing, docs.unoverse.ai/design/apps).
|
|
201
358
|
// It is raw CSS the embed host applies to the app panel ("min(50vw, 760px)", "360px"),
|
|
202
359
|
// never a style the SDK resolves — token law governs the inside, not the envelope.
|
|
203
|
-
|
|
360
|
+
// An Agent file carries URIs and ids, never a style, so the token law has nothing to judge.
|
|
361
|
+
if (!isFixture(file) && !isHook(file) && !isManifest(file) && !(isAgentPath && isAgentPath(file)))
|
|
204
362
|
src.split("\n").forEach((line, i) => {
|
|
205
363
|
/**
|
|
206
364
|
* THE RULE JUDGES CODE, NEVER COMMENTARY. A comment explaining a contrast
|
|
@@ -236,6 +394,44 @@ function lintFile(file) {
|
|
|
236
394
|
if (isFixture(file)) return; // legacy fixture, unused: don't choke
|
|
237
395
|
if (isHook(file)) return; // a lifecycle hook: calls + a projection, not a UI tree
|
|
238
396
|
if (isPropsExtract(file)) return; // the envelope's props map, composed there — not a UI tree
|
|
397
|
+
if (isAgentPath && isAgentPath(file)) { // an Agent: a workflow listed for the map, not a UI tree
|
|
398
|
+
checkAgentFile(report, file, json, componentNamesForFile ? componentNamesForFile(file) : null);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ONE WORD (UNOVERSE_STATE_MODEL §5, "One word: `state`"): a step is a state, so a state tree
|
|
403
|
+
// carries no second name (`on:`), and a step's name is never a face's name.
|
|
404
|
+
if (!isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object" && !Array.isArray(json.states)) {
|
|
405
|
+
const faces = new Set(Object.keys(json.states));
|
|
406
|
+
for (const [face, entry] of Object.entries(json.states)) {
|
|
407
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
408
|
+
if (entry.on !== undefined)
|
|
409
|
+
report("error", file, `state "${face}" names its steps with "on: ${entry.on}". A step is a state: drop "on:" and write state everywhere, Switch on: state, field: state, key: state (UNOVERSE_STATE_MODEL §5). Arrangement picked by data is a Switch in the layout, not a state`);
|
|
410
|
+
if (entry.states && typeof entry.states === "object" && !Array.isArray(entry.states))
|
|
411
|
+
for (const step of Object.keys(entry.states))
|
|
412
|
+
if (faces.has(step)) report("error", file, `state "${face}" has a step named "${step}", which is also a face. With one word, state, a name means one thing: rename the step (UNOVERSE_STATE_MODEL §5)`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// A STATE'S PLACE (UNOVERSE_SCREEN.md §2.3) is `chat` or a Place some app declares. A name no
|
|
417
|
+
// app has sends the interface to the conversation without a word, so it is refused here.
|
|
418
|
+
if (placeNames && !isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object") {
|
|
419
|
+
const known = placeNames();
|
|
420
|
+
const walkPlaces = (states, path) => {
|
|
421
|
+
for (const [name, entry] of Object.entries(states)) {
|
|
422
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
423
|
+
const where = path ? `${path}.${name}` : name;
|
|
424
|
+
if (entry.place !== undefined && known.size > 0) {
|
|
425
|
+
if (typeof entry.place !== "string")
|
|
426
|
+
report("error", file, `state "${where}" place must be a place name: chat, or a Place an app declares (UNOVERSE_SCREEN.md §2.3)`);
|
|
427
|
+
else if (entry.place !== "chat" && !known.has(entry.place))
|
|
428
|
+
report("error", file, `state "${where}" names place "${entry.place}", which no app declares. An interface in this state would land in the conversation instead. Use chat or one of: ${[...known].sort().join(", ")} (UNOVERSE_SCREEN.md §2.3)`);
|
|
429
|
+
}
|
|
430
|
+
if (entry.states && typeof entry.states === "object" && !Array.isArray(entry.states)) walkPlaces(entry.states, where);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
walkPlaces(json.states, "");
|
|
434
|
+
}
|
|
239
435
|
|
|
240
436
|
if (isManifest(file)) {
|
|
241
437
|
const root = dirname(file);
|
|
@@ -424,6 +620,7 @@ function lintFile(file) {
|
|
|
424
620
|
// APP envelope: the states tree lives HERE (one folder grammar) — validate it
|
|
425
621
|
// exactly as the legacy manifest-held tree was.
|
|
426
622
|
if (declared === "app") checkAppTree(json, root, file);
|
|
623
|
+
if (declared === "app") checkAppPlaces(json, root, file);
|
|
427
624
|
if (json.root) walkNode(json.root, file, root);
|
|
428
625
|
|
|
429
626
|
// ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──
|