@unoverse-platform/studio 0.1.90 → 0.1.92
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/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-FILE rules: what a definition must declare about itself.
|
|
3
|
+
*
|
|
4
|
+
* Manifests (discovery meta, whenToUse shape), faces (a Switch on defaultState reaching
|
|
5
|
+
* layouts/<state>), state ordering, microapp discipline (props are inputs, state is scalar
|
|
6
|
+
* view-state). Everything that is about the file as a whole rather than a node within it.
|
|
7
|
+
*
|
|
8
|
+
|
|
9
|
+
* These rules close over the run: the design-system location, the token scale, which refs
|
|
10
|
+
* resolve, which app sizes exist. Rather than thread ten parameters through a recursive
|
|
11
|
+
* walk, each module exports a MAKER that takes the context once and returns the function.
|
|
12
|
+
*
|
|
13
|
+
* The context is created per run by index.mjs, so nothing survives between runs.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join, dirname, basename, relative, sep } from "node:path";
|
|
17
|
+
import { RAW_VALUE, PARTIAL_DIRS, PROP_KEYS, INTERFACE_FIELDS, AUTHORED_CONTROLS } from "./vocabulary.mjs";
|
|
18
|
+
import { isDefFile, defName, defPath, readDef, parseDef } from "./defs.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* THE PROPS MAP IS THE CONTRACT (UNOVERSE_AUTHORING §3b, ruled 2026-08-30). Every field's
|
|
22
|
+
* description, limits, mock and hydration live here now, so this is where they are held
|
|
23
|
+
* to a shape — and where a prop wearing an INTERFACE FIELD's name is told what happens
|
|
24
|
+
* when a delivery carries that field.
|
|
25
|
+
*/
|
|
26
|
+
function checkProps(props, file, report, path) {
|
|
27
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) return;
|
|
28
|
+
for (const [name, spec] of Object.entries(props)) {
|
|
29
|
+
if (name.startsWith("_comment")) continue; // a JSON comment, not a prop
|
|
30
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) continue;
|
|
31
|
+
const where = path ? `${path}${name}` : name;
|
|
32
|
+
for (const k of Object.keys(spec))
|
|
33
|
+
if (!PROP_KEYS.includes(k))
|
|
34
|
+
report("error", file, `prop "${where}" has no "${k}". A prop reads ${PROP_KEYS.map((x) => `"${x}"`).join(", ")} — everything it needs to be understood and written (UNOVERSE_AUTHORING §3b)`);
|
|
35
|
+
// THE DELIVERY WINS. A row fills a component by name with no mapping layer
|
|
36
|
+
// (docs.unoverse.ai/design/interface-data), so `title` on a content-attached card is
|
|
37
|
+
// the row's title. Using the name is right and expected; writing a brief for a field
|
|
38
|
+
// the delivery supplies is the part that quietly never applies.
|
|
39
|
+
if (!path && INTERFACE_FIELDS.has(name)) {
|
|
40
|
+
const controls = AUTHORED_CONTROLS.filter((k) => spec[k] !== undefined);
|
|
41
|
+
if (controls.length)
|
|
42
|
+
report("warn", file, `prop "${name}" is an INTERFACE FIELD, so a delivery fills it by name — and a row's value is not measured against the ${controls.map((c) => `"${c}"`).join(", ")} written here. Keep the name (binding is by name, never mapped); the limit applies only to a value an Agent writes (docs.unoverse.ai/design/interface-data)`);
|
|
43
|
+
}
|
|
44
|
+
if (spec.items && typeof spec.items === "object") checkProps(spec.items, file, report, `${where}.items.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* THE DISCOVERY META CONTRACT (docs.unoverse.ai/nodes/node-discoverability).
|
|
50
|
+
* Four fields, one job each, and the SAME rules for a component, a template and an app:
|
|
51
|
+
* all three are ranked against what a PERSON said, so an app gets these errors rather
|
|
52
|
+
* than the warnings it used to get.
|
|
53
|
+
*
|
|
54
|
+
* The embedded text is "<title>. <whenToUse> <description>" (runtime/bootstrap.ts), so a
|
|
55
|
+
* missing title means the def name, or the folder slug, is the first token the ranker
|
|
56
|
+
* sees. That is a warning rather than an error: the fallback is fine when the name
|
|
57
|
+
* already reads as the thing.
|
|
58
|
+
*/
|
|
59
|
+
const SELECTOR_SHAPED = /\b(pick when|pick whenever|pick for|use (this|when)|when the (user|customer|learner|caller) (asks|wants|says|needs|expresses)|select (this|when))\b/i;
|
|
60
|
+
const MECHANISM_WORDS = /(two-column|\blayout\b|componentslot|\bstreamed\b|\bskeleton\b|\bfull-height\b|\bshell\b)/i;
|
|
61
|
+
|
|
62
|
+
function checkDiscoveryMeta(report, file, json, kind, docs) {
|
|
63
|
+
const desc = typeof json.description === "string" ? json.description.trim() : "";
|
|
64
|
+
if (desc.length < 20)
|
|
65
|
+
report("error", file, `discovery manifest.description missing/too short. One line (\u226520 chars) saying what the ${kind} IS (${docs})`);
|
|
66
|
+
else if (desc.length > 120)
|
|
67
|
+
report("error", file, `discovery manifest.description is ${desc.length} chars: it's the listing subtitle (\u2264120); move detail into whenToUse (${docs})`);
|
|
68
|
+
if (/\buse when\b/i.test(desc))
|
|
69
|
+
report("error", file, `discovery manifest.description smuggles "use when\u2026". Description says what it IS; routing text belongs in whenToUse (${docs})`);
|
|
70
|
+
|
|
71
|
+
const wtu = typeof json.whenToUse === "string" ? json.whenToUse.trim() : "";
|
|
72
|
+
if (wtu.length < 20)
|
|
73
|
+
report("error", file, `discovery manifest.whenToUse missing/too short. The utterance-shaped selection text findIntent ranks on (${docs})`);
|
|
74
|
+
else {
|
|
75
|
+
if (SELECTOR_SHAPED.test(wtu))
|
|
76
|
+
report("error", file, `discovery manifest.whenToUse is selector-shaped. Write the words the USER would say, not instructions about the user (${docs})`);
|
|
77
|
+
const mech = wtu.match(MECHANISM_WORDS);
|
|
78
|
+
if (mech)
|
|
79
|
+
report("error", file, `discovery manifest.whenToUse leads with mechanism ("${mech[0]}"), so it ranks near layout vocabulary instead of the job. Outcome first (${docs})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!(typeof json.title === "string" && json.title.trim()))
|
|
83
|
+
report("warn", file, `discovery manifest has no "title". The embedding is "<title>. <whenToUse> <description>", so the name or the folder slug leads it instead (${docs})`);
|
|
84
|
+
if (!(typeof json.category === "string" && json.category.trim()))
|
|
85
|
+
report("warn", file, `discovery manifest has no "category". Name the domain of the job, never the implementation (${docs})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function makeLintFile(ctx) {
|
|
89
|
+
const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
|
|
90
|
+
|
|
91
|
+
function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
92
|
+
if (!Array.isArray(order)) return;
|
|
93
|
+
const dirNames = (sub) => {
|
|
94
|
+
const d = join(rootFolder, sub);
|
|
95
|
+
return existsSync(d)
|
|
96
|
+
? readdirSync(d).filter(isDefFile).map(defName)
|
|
97
|
+
: [];
|
|
98
|
+
};
|
|
99
|
+
// TEMPLATES: stateOrder lists LOCAL states + LAYOUTS in picker order (docs.unoverse.ai/design/apps) —
|
|
100
|
+
// a layout name (the view a component enters) is a valid entry. COMPONENTS: states only.
|
|
101
|
+
const stateNames = dirNames("states");
|
|
102
|
+
const onDisk = new Set([...stateNames, ...(includeLayouts ? dirNames("layouts") : [])]);
|
|
103
|
+
for (const name of order)
|
|
104
|
+
if (typeof name === "string" && !onDisk.has(name))
|
|
105
|
+
report("error", file, `stateOrder lists "${name}" but no states/${name}${includeLayouts ? ` or layouts/${name}` : ""} definition exists (docs.unoverse.ai/design/${includeLayouts ? "apps" : "components"})`);
|
|
106
|
+
// Only STATES must appear in stateOrder to lock the picker order; the default layout is
|
|
107
|
+
// legitimately omitted, so never warn on layouts.
|
|
108
|
+
for (const name of stateNames)
|
|
109
|
+
if (!order.includes(name))
|
|
110
|
+
report("warn", file, `states/${name}.json is not in stateOrder. It falls to the end of the picker; add it to lock the order (docs.unoverse.ai/design/studio)`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
// THE APP TREE CHECK (extracted 2026-08-29: the tree's home is the ENVELOPE —
|
|
115
|
+
// one folder grammar — while a manifest-held tree remains the legacy read, so
|
|
116
|
+
// both call sites validate identically).
|
|
117
|
+
function checkAppTree(json, root, file) {
|
|
118
|
+
if (json.states !== undefined) {
|
|
119
|
+
if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
|
|
120
|
+
report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
|
|
121
|
+
} else {
|
|
122
|
+
const names = Object.keys(json.states);
|
|
123
|
+
const base = json.layout ?? Object.keys(json.states)[0] ?? "main";
|
|
124
|
+
if (!names.includes(base))
|
|
125
|
+
report("error", file, `the template tree must include the BASE state "${base}" (named for the default layout) — the ladder derives as the top level minus it (STATE_MODEL §5)`);
|
|
126
|
+
const subs = [];
|
|
127
|
+
for (const [n, s] of Object.entries(json.states)) {
|
|
128
|
+
if (s !== null && (typeof s !== "object" || Array.isArray(s)))
|
|
129
|
+
report("error", file, `tree state "${n}" must be an object declaring its layout path (STATE_MODEL §5)`);
|
|
130
|
+
// EXPLICIT PATHS (owner ruling 2026-08-29): every state DECLARES the
|
|
131
|
+
// layout it draws as a root-relative path (layout: layouts/<file>);
|
|
132
|
+
// nothing is assumed from the state's name and no folder is magic.
|
|
133
|
+
const declared = s && typeof s === "object" && typeof s.layout === "string" ? s.layout : undefined;
|
|
134
|
+
if (!declared)
|
|
135
|
+
report("error", file, `tree state "${n}" declares no layout. Every state names its drawing as a path — layout: layouts/${n} (2026-08-29 ruling; the same-name default is retired) (STATE_MODEL §5)`);
|
|
136
|
+
else if (!defPath(root, declared))
|
|
137
|
+
report("error", file, `tree state "${n}" declares layout "${declared}" but no such file exists (STATE_MODEL §5)`);
|
|
138
|
+
const nested = s && typeof s === "object" ? s.states : undefined;
|
|
139
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested))
|
|
140
|
+
for (const [sub, sv] of Object.entries(nested)) {
|
|
141
|
+
subs.push(sub);
|
|
142
|
+
const subDeclared = sv && typeof sv === "object" && typeof sv.layout === "string" ? sv.layout : undefined;
|
|
143
|
+
if (!subDeclared)
|
|
144
|
+
report("error", file, `substate "${sub}" (under "${n}") declares no layout. Every state names its drawing as a path — layout: layouts/${n}-${sub} (the owner-prefix naming convention survives as readability) (2026-08-29 ruling) (LAYERS §3)`);
|
|
145
|
+
else if (!defPath(root, subDeclared))
|
|
146
|
+
report("error", file, `substate "${sub}" (under "${n}") declares layout "${subDeclared}" but no such file exists (LAYERS §3)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const sub of subs)
|
|
150
|
+
if (names.includes(sub))
|
|
151
|
+
report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
|
|
152
|
+
// THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
|
|
153
|
+
// either a SCREEN (a bare name declaring a top-level state) or a substate
|
|
154
|
+
// drawing (<owner>-<sub>, both declared). Anything else is an error — a
|
|
155
|
+
// shared part belongs in components/.
|
|
156
|
+
{
|
|
157
|
+
const ldir = join(root, "layouts");
|
|
158
|
+
if (existsSync(ldir)) {
|
|
159
|
+
const declaredSubs = new Map();
|
|
160
|
+
for (const [n2, s2] of Object.entries(json.states)) {
|
|
161
|
+
const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
|
|
162
|
+
if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
|
|
163
|
+
for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
|
|
164
|
+
}
|
|
165
|
+
// A file NAMED by any state's/substate's declared path is accounted
|
|
166
|
+
// for wherever it sits (2026-08-29: declarations, not names, link).
|
|
167
|
+
const declaredPaths = new Set();
|
|
168
|
+
for (const s3 of Object.values(json.states)) {
|
|
169
|
+
if (s3 && typeof s3 === "object" && typeof s3.layout === "string") declaredPaths.add(s3.layout);
|
|
170
|
+
const nested3 = s3 && typeof s3 === "object" ? s3.states : undefined;
|
|
171
|
+
if (nested3 && typeof nested3 === "object" && !Array.isArray(nested3))
|
|
172
|
+
for (const sv3 of Object.values(nested3))
|
|
173
|
+
if (sv3 && typeof sv3 === "object" && typeof sv3.layout === "string") declaredPaths.add(sv3.layout);
|
|
174
|
+
}
|
|
175
|
+
for (const f of readdirSync(ldir).filter(isDefFile)) {
|
|
176
|
+
const nm = defName(f);
|
|
177
|
+
if (declaredPaths.has(`layouts/${nm}`)) continue;
|
|
178
|
+
if (names.includes(nm) || declaredSubs.has(nm)) continue;
|
|
179
|
+
const owner = names.find((n2) => nm.startsWith(`${n2}-`));
|
|
180
|
+
if (owner)
|
|
181
|
+
report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
|
|
182
|
+
else
|
|
183
|
+
report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (json.stateOrder !== undefined)
|
|
188
|
+
report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
|
|
189
|
+
}
|
|
190
|
+
} else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── lint one file ──
|
|
194
|
+
function lintFile(file) {
|
|
195
|
+
// `readText` is disk unless the caller supplied an overlay for this path (index.mjs).
|
|
196
|
+
// Studio's editor lints unsaved text through exactly these rules that way.
|
|
197
|
+
const src = readText(file);
|
|
198
|
+
|
|
199
|
+
// LAW 1 — tokens only (skip manifest + fixture; styles/ is never in a def home).
|
|
200
|
+
// Exempt: `appWidth` — the HOST-facing outer width (state-owned sizing, docs.unoverse.ai/design/apps).
|
|
201
|
+
// It is raw CSS the embed host applies to the app panel ("min(50vw, 760px)", "360px"),
|
|
202
|
+
// never a style the SDK resolves — token law governs the inside, not the envelope.
|
|
203
|
+
if (!isFixture(file) && !isHook(file) && !isManifest(file))
|
|
204
|
+
src.split("\n").forEach((line, i) => {
|
|
205
|
+
/**
|
|
206
|
+
* THE RULE JUDGES CODE, NEVER COMMENTARY. A comment explaining a contrast
|
|
207
|
+
* decision legitimately names the hex it rejected ("#E9F1FF dropped it to
|
|
208
|
+
* 1.14:1"), and scanning the raw line flagged exactly the best-documented
|
|
209
|
+
* files (observed live 2026-08-21: two comment hexes blocked a deploy).
|
|
210
|
+
* YAML comments start at an unquoted #, at line start or after whitespace;
|
|
211
|
+
* everything from there is prose. A quoted "#fff" VALUE stays visible to the
|
|
212
|
+
* rule, which is the whole point of the law.
|
|
213
|
+
*/
|
|
214
|
+
let code = line, inS = false, inD = false;
|
|
215
|
+
for (let j = 0; j < line.length; j++) {
|
|
216
|
+
const ch = line[j];
|
|
217
|
+
if (ch === "'" && !inD) inS = !inS;
|
|
218
|
+
else if (ch === '"' && !inS) inD = !inD;
|
|
219
|
+
else if (ch === "#" && !inS && !inD && (j === 0 || /\s/.test(line[j - 1]))) {
|
|
220
|
+
code = line.slice(0, j);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (RAW_VALUE.test(code) && !/^\s*"?appWidth"?\s*:/.test(code))
|
|
225
|
+
report("error", file, `raw value. Token names only; add/scale a token in the org styles instead (LAW 1, docs.unoverse.ai/design/styles-and-tokens): ${code.trim()}`, i + 1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
let json;
|
|
229
|
+
try {
|
|
230
|
+
json = parseDef(src, file);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
report("error", file, `invalid ${file.endsWith(".yaml") ? "YAML" : "JSON"}: ${e.message}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isFixture(file)) return; // legacy fixture, unused: don't choke
|
|
237
|
+
if (isHook(file)) return; // a lifecycle hook: calls + a projection, not a UI tree
|
|
238
|
+
if (isPropsExtract(file)) return; // the envelope's props map, composed there — not a UI tree
|
|
239
|
+
|
|
240
|
+
if (isManifest(file)) {
|
|
241
|
+
const root = dirname(file);
|
|
242
|
+
if (isAppPath(file)) {
|
|
243
|
+
// TEMPLATE manifest = the envelope. Requires binding + a resolvable root.
|
|
244
|
+
if (!json.name) report("warn", file, `app manifest missing "name": the display name (docs.unoverse.ai/design/apps)`);
|
|
245
|
+
checkDiscoveryMeta(report, file, json, "app", "docs.unoverse.ai/design/apps");
|
|
246
|
+
if (!(json.binding && json.binding.workflow))
|
|
247
|
+
report("warn", file, `template manifest has no binding.workflow. The app owns its workflow binding (docs.unoverse.ai/design/apps)`);
|
|
248
|
+
// Two valid roots (definitions.ts:229): the STANDARD manifest-only form (root =
|
|
249
|
+
// layouts/<layout>), OR a `<name>` envelope OVERRIDE (its own root). Only the
|
|
250
|
+
// manifest-only form must resolve a layout; an envelope-form template supplies its own.
|
|
251
|
+
const hasEnvelope = !!defPath(root, basename(root));
|
|
252
|
+
if (!hasEnvelope) {
|
|
253
|
+
// The base defaults to the FIRST DECLARED STATE (states own layouts and
|
|
254
|
+
// declaration order ranks them); "main" only when no states block exists.
|
|
255
|
+
const firstState = json.states && typeof json.states === "object" && !Array.isArray(json.states)
|
|
256
|
+
? Object.keys(json.states)[0]
|
|
257
|
+
: undefined;
|
|
258
|
+
// The base state's DECLARED path wins (2026-08-29 ruling: layout is a
|
|
259
|
+
// root-relative path, nothing assumed); the name fallback survives only
|
|
260
|
+
// for the unswept installed tier.
|
|
261
|
+
const firstEntry = firstState ? json.states[firstState] : undefined;
|
|
262
|
+
const declaredBase = firstEntry && typeof firstEntry === "object" && typeof firstEntry.layout === "string" ? firstEntry.layout : undefined;
|
|
263
|
+
if (declaredBase) {
|
|
264
|
+
if (!defPath(root, declaredBase))
|
|
265
|
+
report("error", file, `base state "${firstState}" declares layout "${declaredBase}" but no such file exists (docs.unoverse.ai/design/apps)`);
|
|
266
|
+
} else {
|
|
267
|
+
const layoutName = json.layout ?? firstState ?? "main";
|
|
268
|
+
if (!defPath(join(root, "layouts"), layoutName))
|
|
269
|
+
report("error", file, `manifest.layout "${layoutName}" → layouts/${layoutName} does not exist (and no <name> envelope) (docs.unoverse.ai/design/apps)`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// THE TEMPLATE TREE (STATE_MODEL v2, checkpoint 2026-08-08): a manifest
|
|
273
|
+
// `states:` block declares the whole machine — validate the DECLARATION:
|
|
274
|
+
// top-level order is the ladder (base first by convention, named for the
|
|
275
|
+
// default layout); every non-base top-level state needs its arrangement
|
|
276
|
+
// (layouts/<name>); every base substate needs its state file
|
|
277
|
+
// (states/<name>); a name cannot be both; an authored stateOrder is
|
|
278
|
+
// superseded and should be deleted.
|
|
279
|
+
// ONE DRAWINGS FOLDER (owner ruling 2026-08-22): templates work like
|
|
280
|
+
// components — every drawing lives in layouts/, the tree decides which state
|
|
281
|
+
// owns which. A states/ folder is the pre-unification anatomy: move its files
|
|
282
|
+
// into layouts/ and update $include paths.
|
|
283
|
+
if (existsSync(join(root, "states")))
|
|
284
|
+
report("error", file, `template carries a states/ folder — templates have ONE drawings folder. Move states/* into layouts/ and update every "$include: states/<name>" to "layouts/<name>" (anatomy unified with components, LAYERS §3)`);
|
|
285
|
+
checkAppTree(json, root, file);
|
|
286
|
+
// ONE STATE AT A TIME (docs.unoverse.ai/design/state): the active state is derived from the
|
|
287
|
+
// latest surfaced VIEW, so no two surfaces in one template may claim the same
|
|
288
|
+
// view — the active surface would be ambiguous.
|
|
289
|
+
{
|
|
290
|
+
const claims = new Map(); // view -> first file
|
|
291
|
+
const collectClaims = (n, from) => {
|
|
292
|
+
if (Array.isArray(n)) return n.forEach((c) => collectClaims(c, from));
|
|
293
|
+
if (!n || typeof n !== "object") return;
|
|
294
|
+
const w = n.type === "ComponentSlot" ? n.select?.where : null;
|
|
295
|
+
if ((w?.field === "state" || w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
|
|
296
|
+
if (claims.has(w.eq))
|
|
297
|
+
report("error", file, `two reaction surfaces claim the view "${w.eq}" (${claims.get(w.eq)} and ${from}). A template is in ONE state at a time; each view has exactly one surface (docs.unoverse.ai/design/state)`);
|
|
298
|
+
else claims.set(w.eq, from);
|
|
299
|
+
}
|
|
300
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") collectClaims(v, from);
|
|
301
|
+
};
|
|
302
|
+
for (const sub of ["layouts", "states", "components"]) {
|
|
303
|
+
const d = join(root, sub);
|
|
304
|
+
if (!existsSync(d)) continue;
|
|
305
|
+
for (const f of readdirSync(d).filter(isDefFile)) {
|
|
306
|
+
try {
|
|
307
|
+
collectClaims(readDef(join(d, f)), `${sub}/${f}`);
|
|
308
|
+
} catch {
|
|
309
|
+
/* that file lints separately */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (json.mode !== undefined && json.defaultState === undefined)
|
|
315
|
+
report("warn", file, `"mode" was renamed to "defaultState". Still read as a fallback, but rename it (docs.unoverse.ai/design/state)`);
|
|
316
|
+
// `preview` — the per-state MOCK map ({ "<state>": ["course-card", …] }): each key
|
|
317
|
+
// must be a states/ file, each name a real component. A repeated name seeds
|
|
318
|
+
// several instances (a card rail).
|
|
319
|
+
if (json.preview !== undefined) {
|
|
320
|
+
if (!json.preview || typeof json.preview !== "object" || Array.isArray(json.preview))
|
|
321
|
+
report("error", file, `"preview" must be an object mapping state names to component-name arrays (docs.unoverse.ai/design/studio)`);
|
|
322
|
+
else {
|
|
323
|
+
// preview keys are per-LAYOUT (the component view Studio mocks) or a local state
|
|
324
|
+
// (docs.unoverse.ai/design/apps) — resolve against states/ ∪ layouts/, same as stateOrder.
|
|
325
|
+
const viewsIn = (sub) => {
|
|
326
|
+
const d = join(root, sub);
|
|
327
|
+
return existsSync(d)
|
|
328
|
+
? readdirSync(d).filter(isDefFile).map(defName)
|
|
329
|
+
: [];
|
|
330
|
+
};
|
|
331
|
+
const states = new Set([...viewsIn("states"), ...viewsIn("layouts")]);
|
|
332
|
+
// ANATOMY UNIFIED (2026-08-22): a substate's drawing lives in layouts/ under
|
|
333
|
+
// `<arrangement>-<substate>`, so the STATE's own name is no longer a filename.
|
|
334
|
+
// The resolver already looks both ways (definitions.ts, template tree); without
|
|
335
|
+
// the same lookup here a correctly-migrated template lints as though every one
|
|
336
|
+
// of its substates had vanished.
|
|
337
|
+
// The tree's home is the ENVELOPE (one folder grammar, 2026-08-29); a
|
|
338
|
+
// manifest-held tree is the legacy read.
|
|
339
|
+
const envPath = defPath(root, basename(root));
|
|
340
|
+
const envStates = envPath ? readDef(envPath)?.states : undefined;
|
|
341
|
+
const treeStates = (json.states && typeof json.states === "object" && !Array.isArray(json.states) ? json.states : undefined)
|
|
342
|
+
?? (envStates && typeof envStates === "object" && !Array.isArray(envStates) ? envStates : {});
|
|
343
|
+
for (const [parent, node] of Object.entries(treeStates)) {
|
|
344
|
+
states.add(parent);
|
|
345
|
+
for (const sub of Object.keys(node?.states ?? {})) if (states.has(`${parent}-${sub}`)) states.add(sub);
|
|
346
|
+
}
|
|
347
|
+
const comps = componentNamesForFile(file);
|
|
348
|
+
for (const [state, list] of Object.entries(json.preview)) {
|
|
349
|
+
if (!states.has(state))
|
|
350
|
+
report("error", file, `preview."${state}". No states/${state}.json or layouts/${state}.json in this template (docs.unoverse.ai/design/studio)`);
|
|
351
|
+
// An OBJECT entry is authored TEMPLATE-STATE mock data (what the workflow
|
|
352
|
+
// would have echoed — comments, a discriminant, an anchor), merged verbatim
|
|
353
|
+
// when the state's pill is picked. Only the two shapes; anything else errors.
|
|
354
|
+
if (list && typeof list === "object" && !Array.isArray(list)) continue;
|
|
355
|
+
if (!Array.isArray(list) || list.some((c) => typeof c !== "string")) {
|
|
356
|
+
report("error", file, `preview."${state}" must be an array of component names, or an object of template-state mock data`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const c of list)
|
|
360
|
+
if (comps && !comps.has(String(c).toLowerCase()))
|
|
361
|
+
report("error", file, `preview."${state}" names unknown component "${c}". No match in design/marketplace/components/ or this org's components/ (org-privacy: another org's components are out of reach; lookup is case-insensitive)`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Sizing is STATE-OWNED (docs.unoverse.ai/design/apps): the layout root's `appWidth` is the core
|
|
366
|
+
// surface's constant width; a panel slot's `appWidth` slides out on top. Manifest
|
|
367
|
+
// width/focusWidth are DEAD — nothing reads them; there is no fallback.
|
|
368
|
+
for (const dep of ["width", "focusWidth"])
|
|
369
|
+
if (json[dep] !== undefined)
|
|
370
|
+
report("error", file, `manifest "${dep}" is dead. Nothing reads it. Sizing is state-owned: \`appWidth\` on the layout root (constant core width) or on a panel (slide-out width) (docs.unoverse.ai/design/apps)`);
|
|
371
|
+
} else {
|
|
372
|
+
// COMPONENT manifest = OPTIONAL spatial discovery. No binding. Mirrors the
|
|
373
|
+
// discovery-meta assertions in server/src/runtime/microapp-structure.test.ts.
|
|
374
|
+
const isTpl = /[\\/]templates[\\/]/.test(file);
|
|
375
|
+
checkDiscoveryMeta(report, file, json, isTpl ? "template" : "component",
|
|
376
|
+
isTpl ? "docs.unoverse.ai/design/templates" : "docs.unoverse.ai/design/components");
|
|
377
|
+
if (json.binding)
|
|
378
|
+
report("warn", file, `a component discovery manifest has no workflow. Drop "binding" (a component is streamed or node-hydrated) (docs.unoverse.ai/design/components)`);
|
|
379
|
+
// `lifetime` — OPTIONAL render lifetime (docs.unoverse.ai/design/state §Two lifetimes). Closed set:
|
|
380
|
+
// "turn" (default — the universal new-turn reset) | "conversation" (durable
|
|
381
|
+
// conversation-scoped surface: conversation-keyed instance, exempt from the
|
|
382
|
+
// new-turn reset, retired only by replacement, self-close, or a template swap).
|
|
383
|
+
if (json.lifetime !== undefined && json.lifetime !== "turn" && json.lifetime !== "conversation")
|
|
384
|
+
report("error", file, `manifest "lifetime" must be "turn" (default) or "conversation". Got ${JSON.stringify(json.lifetime)} (docs.unoverse.ai/design/state §Two lifetimes)`);
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const isEnvelope = typeof json.unoverse === "string";
|
|
390
|
+
const root = defRoot(file);
|
|
391
|
+
|
|
392
|
+
if (isEnvelope) {
|
|
393
|
+
// The declaration word is `type:`; `kind:` is the legacy spelling, still read.
|
|
394
|
+
// ONE FOLDER GRAMMAR (owner ruling 2026-08-29): every kind has an envelope
|
|
395
|
+
// saying what it IS. component/atom = root; template = parts+shapes; app =
|
|
396
|
+
// the states tree (its manifest stays the MCP face).
|
|
397
|
+
const declared = json.type ?? json.kind;
|
|
398
|
+
// Template grammar §9b (2026-08-29): states like every kind; parts/shapes = legacy.
|
|
399
|
+
// ONE REQUIREMENT SHAPE (2026-08-29): a kind declares its tree (`states:`) OR
|
|
400
|
+
// its drawing (`root` — flat components/atoms). A tree component's root is
|
|
401
|
+
// SYNTHESIZED by the compiler, never required.
|
|
402
|
+
// A BLOCK is a template that declares a DRAWING instead of a tree (2026-08-31): a flat
|
|
403
|
+
// file, no manifest, one `root:` — `pair`, `feature`, `single`, `rail`, `stack`. It has
|
|
404
|
+
// no states because it is not a screen: it is a shape a page imports by `Ref`, inlined
|
|
405
|
+
// before the page's sections compile. Requiring `states:` of it would be demanding a
|
|
406
|
+
// state machine from a fragment, which is the same mistake as demanding a manifest:
|
|
407
|
+
// both are what a PAGE has, and a block is not a page.
|
|
408
|
+
const isBlock = declared === "template" && json.states === undefined && json.root !== undefined;
|
|
409
|
+
const envelopeRequired =
|
|
410
|
+
isBlock ? ["name", "root"]
|
|
411
|
+
: declared === "app" || declared === "template" ? ["name", "states"]
|
|
412
|
+
: declared === "component" ? ["name", ...(json.states !== undefined || json.state?.view !== undefined ? [] : ["root"])]
|
|
413
|
+
: ["name", "root"];
|
|
414
|
+
if (declared === "template" && json.states === undefined && (json.parts !== undefined || json.shapes !== undefined)) envelopeRequired.pop();
|
|
415
|
+
for (const req of envelopeRequired)
|
|
416
|
+
if (json[req] === undefined) report("error", file, `envelope missing "${req}" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
417
|
+
if (declared === undefined) report("error", file, `envelope missing "type" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
418
|
+
if (json.kind !== undefined && json.type === undefined)
|
|
419
|
+
report("warn", file, `"kind" is the legacy spelling of the declaration — write "type: ${json.kind}"`);
|
|
420
|
+
if (declared && !["component", "template", "atom", "app"].includes(declared))
|
|
421
|
+
report("error", file, `unknown type "${declared}"`);
|
|
422
|
+
if (declared === "component" && !json.category)
|
|
423
|
+
report("warn", file, `component has no "category". Used to group it in the palette (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
424
|
+
// APP envelope: the states tree lives HERE (one folder grammar) — validate it
|
|
425
|
+
// exactly as the legacy manifest-held tree was.
|
|
426
|
+
if (declared === "app") checkAppTree(json, root, file);
|
|
427
|
+
if (json.root) walkNode(json.root, file, root);
|
|
428
|
+
|
|
429
|
+
// ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──
|
|
430
|
+
if (declared === "component") {
|
|
431
|
+
// A TEMPLATE'S OWN PART IS A FLAT COMPONENT (TEMPLATE_MODEL §9b rule 7): one file,
|
|
432
|
+
// no manifest, no state tree — the face IS the filename. Its `root` here is the
|
|
433
|
+
// TEMPLATE's folder, which does carry `layouts/`, so without this the whole faced-
|
|
434
|
+
// component discipline fires on every part and demands a Switch it must not have.
|
|
435
|
+
const hasLayouts = !isTemplatePart(file) && existsSync(join(root, "layouts"));
|
|
436
|
+
const statesDir = join(root, "states");
|
|
437
|
+
const stateFiles = existsSync(statesDir)
|
|
438
|
+
? readdirSync(statesDir).filter(isDefFile).map(defName).sort()
|
|
439
|
+
: [];
|
|
440
|
+
const hasStateBlock = json.state && typeof json.state === "object";
|
|
441
|
+
|
|
442
|
+
// THE DISCOVERY MANIFEST IS THE SINGLE HOME for the meta — but the envelope's own
|
|
443
|
+
// `description` is now COMPOSITION CONTEXT (UNOVERSE_AUTHORING §3b, 2026-08-30):
|
|
444
|
+
// what an Agent filling the WHOLE of this must know, which used to ride an unbound
|
|
445
|
+
// `brief` on the layout root. Two files, two audiences. Only a COPY of the
|
|
446
|
+
// manifest's text is the duplication this rule exists to stop.
|
|
447
|
+
{
|
|
448
|
+
const mPath = defPath(root, "manifest");
|
|
449
|
+
let m = null;
|
|
450
|
+
if (mPath) { try { m = readDef(mPath); } catch { m = null; } }
|
|
451
|
+
if (m) {
|
|
452
|
+
if (json.whenToUse !== undefined)
|
|
453
|
+
report("error", file, `envelope duplicates manifest meta "whenToUse". The discovery manifest is the single home (docs.unoverse.ai/design/components)`);
|
|
454
|
+
if (typeof json.description === "string" && json.description.trim() === String(m.description ?? "").trim())
|
|
455
|
+
report("error", file, `the envelope's "description" is a copy of the manifest's. The manifest is the single home for the DISCOVERY line; the envelope's own description is composition context — what an Agent filling the whole of this must know (UNOVERSE_AUTHORING §3b)`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// deprecated bridge: a top-level `defaultState` triggers the component node APP_DATA emit
|
|
460
|
+
if (json.defaultState !== undefined)
|
|
461
|
+
report("warn", file, `top-level "defaultState" is the deprecated bridge (component-node APP_DATA emit). The master state lives in the \`state\` block; templates react via ComponentSlot.select.where (STATE_MODEL §5b)`);
|
|
462
|
+
|
|
463
|
+
// THE PROP IS THE BRIEF (UNOVERSE_AUTHORING §3b, 2026-08-30): a prop declares
|
|
464
|
+
// everything about itself, so the props map is the whole contract and is held to a
|
|
465
|
+
// closed shape. The props extract composes first (`props: { $include: props }` —
|
|
466
|
+
// the loader does the same before any reader sees the map).
|
|
467
|
+
let judgedProps = json.props ?? {};
|
|
468
|
+
if (typeof judgedProps.$include === "string") {
|
|
469
|
+
const incPath = defPath(root, judgedProps.$include);
|
|
470
|
+
if (!incPath) {
|
|
471
|
+
report("error", file, `props $include "${judgedProps.$include}" resolves to no file in the definition folder (docs.unoverse.ai/design/components)`);
|
|
472
|
+
judgedProps = {};
|
|
473
|
+
} else {
|
|
474
|
+
try { judgedProps = readDef(incPath) ?? {}; } catch { judgedProps = {}; }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
checkProps(judgedProps, file, report, "");
|
|
478
|
+
|
|
479
|
+
// EVERY PROP CARRIES A DESCRIPTION (UNOVERSE_AUTHORING §3b, RULED 2026-08-30).
|
|
480
|
+
//
|
|
481
|
+
// It reads BOTH ways, which is why it is compulsory rather than encouraged. A
|
|
482
|
+
// description is not only what a copywriter fills against: it is what lets an agent
|
|
483
|
+
// UNDERSTAND the screen it is looking at. `read` reports where the person is; the
|
|
484
|
+
// descriptions say what is actually in front of them. A screen of unnamed fields is
|
|
485
|
+
// one no agent can reason about, in either direction.
|
|
486
|
+
//
|
|
487
|
+
// AND IT SAYS NOTHING ABOUT PERMISSION. That is the layout's word — `static` /
|
|
488
|
+
// `copywriter` / `director` (TEMPLATE_MODEL §9b) — never a prop's. The rule this
|
|
489
|
+
// replaces was "a described prop is a writable prop", which made silence the safety
|
|
490
|
+
// mechanism: a field was protected because nobody had explained it, and explaining it
|
|
491
|
+
// was what exposed it. Describing a field is now free of consequence, so there is no
|
|
492
|
+
// longer any reason to leave one blank.
|
|
493
|
+
const undescribed = Object.entries(judgedProps)
|
|
494
|
+
.filter(([, v]) => !(v && typeof v === "object" && typeof v.description === "string" && v.description.trim()))
|
|
495
|
+
.map(([k]) => k);
|
|
496
|
+
if (undescribed.length)
|
|
497
|
+
report("error", file, `props [${undescribed.join(", ")}] carry no "description". Every prop says what it IS, so an agent can read the screen as well as fill it. A description grants nothing: permission is the layout's word (static / copywriter / director), never a prop's (UNOVERSE_AUTHORING §3b)`);
|
|
498
|
+
|
|
499
|
+
// only components that ADOPTED the structure are held to the full discipline
|
|
500
|
+
if (hasLayouts || stateFiles.length || hasStateBlock) {
|
|
501
|
+
// Every prop declares what fills it. Was "must be input:true", from when props
|
|
502
|
+
// only held workflow data; shared components added props nothing streams in (the
|
|
503
|
+
// preview axis, literals passed via `Ref with`) — input:true there is false AND
|
|
504
|
+
// load-bearing, since configSchema derives from it. Silence is the error:
|
|
505
|
+
// unflagged props default to INPUTS.
|
|
506
|
+
// The props extract composes first (`props: { $include: props }` — the loader
|
|
507
|
+
// does the same before any reader sees the map).
|
|
508
|
+
const undeclared = Object.entries(judgedProps)
|
|
509
|
+
.filter(([, v]) => !(v && typeof v === "object" && typeof v.input === "boolean"))
|
|
510
|
+
.map(([k]) => k);
|
|
511
|
+
if (undeclared.length)
|
|
512
|
+
report("error", file, `microapp props [${undeclared.join(", ")}] do not declare "input". Every prop says what fills it: input:true = a workflow streams it (it joins the node's configSchema); input:false = nothing does (a preview axis, or a literal the host passes via Ref with). Unflagged props default to INPUTS (docs.unoverse.ai/design/components)`);
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
// STATE MODEL v2 (UNOVERSE_STATE_MODEL §5): an authored `state.view` TREE is
|
|
516
|
+
// the component's state machine — the ONE object the scalar rule admits.
|
|
517
|
+
// Well-formed = { initial?: string, states: { <name>: { layout?/layouts?/on?/
|
|
518
|
+
// initial?/states? } } }. A malformed tree is still an error.
|
|
519
|
+
const isViewTree = (v) => {
|
|
520
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
521
|
+
if (v.initial !== undefined && typeof v.initial !== "string") return false;
|
|
522
|
+
if (!v.states || typeof v.states !== "object" || Array.isArray(v.states)) return false;
|
|
523
|
+
return Object.values(v.states).every(
|
|
524
|
+
(s) => s && typeof s === "object" && !Array.isArray(s) &&
|
|
525
|
+
(s.on === undefined || typeof s.on === "string") &&
|
|
526
|
+
(s.initial === undefined || typeof s.initial === "string") &&
|
|
527
|
+
(s.layout === undefined || typeof s.layout === "string"),
|
|
528
|
+
);
|
|
529
|
+
};
|
|
530
|
+
// THE RULED FORM (2026-08-29): the tree lives at TOP-LEVEL `states:` like
|
|
531
|
+
// every kind; `state.view` is the legacy wrapper, still read.
|
|
532
|
+
const topTree = json.states && typeof json.states === "object" && !Array.isArray(json.states) && isViewTree({ states: json.states }) ? { states: json.states } : null;
|
|
533
|
+
const viewTree = topTree ?? (hasStateBlock && isViewTree(json.state.view) ? json.state.view : null);
|
|
534
|
+
|
|
535
|
+
// the state block holds SCALAR internal view-state ONLY — an array/object (a
|
|
536
|
+
// finder's result rows) or a URL is content/data slop: hardcode it in the layout,
|
|
537
|
+
// or move workflow-fed data to props (input:true) (AUTHORING §3). The one
|
|
538
|
+
// exception: a well-formed v2 `state.view` tree (STATE_MODEL §5 rule 1).
|
|
539
|
+
const scalarHomes = [];
|
|
540
|
+
if (hasStateBlock) scalarHomes.push(["state", json.state]);
|
|
541
|
+
if (json.values && typeof json.values === "object" && !Array.isArray(json.values)) scalarHomes.push(["values", json.values]);
|
|
542
|
+
for (const [home, blk] of scalarHomes)
|
|
543
|
+
for (const [k, v] of Object.entries(blk)) {
|
|
544
|
+
if (home === "state" && k === "view" && viewTree && !topTree) continue; // legacy wrapper only
|
|
545
|
+
if (Array.isArray(v) || (v && typeof v === "object"))
|
|
546
|
+
report("error", file, `${home}.${k} is an ${Array.isArray(v) ? "array" : "object"}. The values block is SCALAR starting values only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs.unoverse.ai/design/components)`);
|
|
547
|
+
else if (typeof v === "string" && /^https?:\/\//.test(v))
|
|
548
|
+
report("error", file, `${home}.${k} is a URL. Content, not a starting value; hardcode it in the layout (or props input:true if workflow-fed) (docs.unoverse.ai/design/components)`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (hasLayouts) {
|
|
552
|
+
const raw = JSON.stringify(json.root ?? {}).replace(/\s/g, "");
|
|
553
|
+
if (viewTree && json.root == null) {
|
|
554
|
+
// THE TREE IS THE MACHINE (2026-08-29 late): no authored root — the
|
|
555
|
+
// compiler synthesizes the Switch from the tree. Nothing to check.
|
|
556
|
+
} else if (viewTree) {
|
|
557
|
+
// An AUTHORED root is a real shell (beyond the plain Switch) — it must
|
|
558
|
+
// still switch the public axis. A root that merely restates the tree is
|
|
559
|
+
// redundant: delete it and let the compiler synthesize.
|
|
560
|
+
if ((!raw.includes('"on":"state"') && !raw.includes('"on":"view"')) || !/"\$include":"layouts\//.test(raw))
|
|
561
|
+
report("error", file, `a tree component's authored root must Switch on "state" (the ruled public-axis word; "view" is the legacy spelling) → $include layouts/<layout> (STATE_MODEL §5)`);
|
|
562
|
+
const rootObj = json.root ?? {};
|
|
563
|
+
const kids = Array.isArray(rootObj.children) ? rootObj.children : [];
|
|
564
|
+
if (rootObj.type === "Box" && kids.length === 1 && kids[0]?.type === "Switch")
|
|
565
|
+
report("warn", file, `the root is only the tree's Switch restated — delete it; the compiler synthesizes the root from \`states:\` (2026-08-29 ruling)`);
|
|
566
|
+
} else if (!raw.includes('"on":"defaultState"') || !/"\$include":"layouts\//.test(raw))
|
|
567
|
+
report("error", file, `a faced component's root must Switch on defaultState → $include layouts/<state> (legacy; or declare a v2 state.view tree and Switch on "view") (docs.unoverse.ai/design/components)`);
|
|
568
|
+
|
|
569
|
+
// ── face set ⇄ layouts/ cross-check (OPEN name set — inline/focused/<any>) ──
|
|
570
|
+
// The FACES are the root Switch's cases; Studio's face toggle and the render
|
|
571
|
+
// path both derive from them, so cases and layout files must agree exactly.
|
|
572
|
+
const axes = viewTree ? ["state", "view"] : ["defaultState"];
|
|
573
|
+
const findFaceSwitch = (n) => {
|
|
574
|
+
if (!n || typeof n !== "object") return null;
|
|
575
|
+
if (Array.isArray(n)) { for (const c of n) { const r = findFaceSwitch(c); if (r) return r; } return null; }
|
|
576
|
+
if (n.type === "Switch" && axes.includes(n.on) && n.cases && typeof n.cases === "object") return n;
|
|
577
|
+
for (const v of Object.values(n)) { const r = findFaceSwitch(v); if (r) return r; }
|
|
578
|
+
return null;
|
|
579
|
+
};
|
|
580
|
+
const faceSwitch = findFaceSwitch(json.root);
|
|
581
|
+
if (faceSwitch) {
|
|
582
|
+
const cases = faceSwitch.cases;
|
|
583
|
+
const caseNames = Object.keys(cases).filter((k) => k !== "default");
|
|
584
|
+
// inline is the UNIVERSAL default face: an unknown/absent defaultState must
|
|
585
|
+
// render SOMETHING — require an `inline` case or an explicit `default`.
|
|
586
|
+
// EXCEPTION — a SURFACE-ONLY component (docs.unoverse.ai/design/components): its manifest
|
|
587
|
+
// declares a SURFACED arrival (defaultState naming one of its cases, not
|
|
588
|
+
// inline), and it deliberately renders NOTHING while unsurfaced (e.g. a
|
|
589
|
+
// rail card retired by a new turn). Then omitting inline/default is the
|
|
590
|
+
// point, not a mistake.
|
|
591
|
+
if (!caseNames.includes("inline") && cases.default === undefined) {
|
|
592
|
+
// v2: the tree's `initial` IS the declared base — the component always
|
|
593
|
+
// has somewhere to rest, so no inline/default case is required as long
|
|
594
|
+
// as the initial names a real case.
|
|
595
|
+
const treeInitial = viewTree ? (viewTree.initial ?? Object.keys(viewTree.states)[0]) : undefined;
|
|
596
|
+
let arrival;
|
|
597
|
+
try {
|
|
598
|
+
const mp = defPath(root, "manifest");
|
|
599
|
+
if (mp) arrival = readDef(mp).defaultState;
|
|
600
|
+
} catch { /* linted separately */ }
|
|
601
|
+
const surfaceOnly = typeof arrival === "string" && arrival !== "inline" && caseNames.includes(arrival);
|
|
602
|
+
const v2Based = typeof treeInitial === "string" && caseNames.includes(treeInitial);
|
|
603
|
+
if (!surfaceOnly && !v2Based)
|
|
604
|
+
report("error", file, `faced component's Switch has no "inline" case and no "default". Inline is the universal default face; a component with neither disappears for unknown states. (v2: the state.view tree's \`initial\` naming a case also satisfies this; legacy surface-only: manifest.defaultState naming one of its cases.) (STATE_MODEL §5)`);
|
|
605
|
+
}
|
|
606
|
+
// Each named case's layout include must MATCH the case name — the layout
|
|
607
|
+
// filename IS the state name (Studio writes defaultState=<case>).
|
|
608
|
+
const usedLayouts = new Set();
|
|
609
|
+
for (const [name, body] of Object.entries(cases)) {
|
|
610
|
+
const inc = body && typeof body === "object" ? body.$include : undefined;
|
|
611
|
+
if (typeof inc !== "string" || !inc.startsWith("layouts/")) continue;
|
|
612
|
+
const layoutName = inc.slice("layouts/".length);
|
|
613
|
+
usedLayouts.add(layoutName);
|
|
614
|
+
// v2: the STATE owns its layout — the tree's declaration decides which
|
|
615
|
+
// file draws it. Declarations are root-relative PATHS (2026-08-29
|
|
616
|
+
// ruling); a bare name still reads as layouts/<name> for the unswept
|
|
617
|
+
// installed tier.
|
|
618
|
+
const declaredLayout = viewTree?.states?.[name]?.layout;
|
|
619
|
+
const expected = declaredLayout
|
|
620
|
+
? (declaredLayout.includes("/") ? declaredLayout : `layouts/${declaredLayout}`).replace(/^layouts\//, "")
|
|
621
|
+
: name;
|
|
622
|
+
if (name !== "default" && layoutName !== expected)
|
|
623
|
+
report("error", file, viewTree
|
|
624
|
+
? `state "${name}" declares layout "${expected}" but its case includes layouts/${layoutName} — the tree's declaration and the case must agree (STATE_MODEL §5 rule 1)`
|
|
625
|
+
: `face case "${name}" includes layouts/${layoutName}. The layout FILENAME is the state name; rename one so they match (docs.unoverse.ai/design/components)`);
|
|
626
|
+
}
|
|
627
|
+
// v2: tree-declared layouts (including nested substates' and variants') are
|
|
628
|
+
// reachable by declaration — seed them so the orphan check knows them.
|
|
629
|
+
if (viewTree) {
|
|
630
|
+
// Declared values are PATHS (2026-08-29); the orphan set is keyed by
|
|
631
|
+
// layouts/-relative names, so strip the prefix (bare legacy names
|
|
632
|
+
// pass through unchanged).
|
|
633
|
+
const asName = (l) => l.replace(/^layouts\//, "");
|
|
634
|
+
const seed = (states) => {
|
|
635
|
+
for (const s of Object.values(states ?? {})) {
|
|
636
|
+
if (!s || typeof s !== "object") continue;
|
|
637
|
+
if (typeof s.layout === "string") usedLayouts.add(asName(s.layout));
|
|
638
|
+
if (s.layouts && typeof s.layouts === "object")
|
|
639
|
+
for (const l of Object.values(s.layouts)) if (typeof l === "string") usedLayouts.add(asName(l));
|
|
640
|
+
if (s.states) seed(s.states);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
seed(viewTree.states);
|
|
644
|
+
}
|
|
645
|
+
// Orphan faces: a layouts/*.json no case references is invisible — Studio's
|
|
646
|
+
// face toggle and the renderer only know the Switch's cases.
|
|
647
|
+
//
|
|
648
|
+
// REACHABILITY IS TRANSITIVE. A face may itself be a Switch on some other key
|
|
649
|
+
// (a `course` face switching on `step` into `course-detail` and `course-apply`),
|
|
650
|
+
// and those layouts are reached only from inside it. Counting the outer cases
|
|
651
|
+
// alone called them orphans and told the developer to delete two live files.
|
|
652
|
+
//
|
|
653
|
+
// So follow every include from the faces outward, to a fixed point. Anything
|
|
654
|
+
// still unreached is genuinely unreachable.
|
|
655
|
+
const layoutsDir = join(root, "layouts");
|
|
656
|
+
const includesIn = (node, into) => {
|
|
657
|
+
if (!node || typeof node !== "object") return into;
|
|
658
|
+
if (Array.isArray(node)) { for (const v of node) includesIn(v, into); return into; }
|
|
659
|
+
if (typeof node.$include === "string" && node.$include.startsWith("layouts/"))
|
|
660
|
+
into.add(node.$include.slice("layouts/".length));
|
|
661
|
+
for (const v of Object.values(node)) includesIn(v, into);
|
|
662
|
+
return into;
|
|
663
|
+
};
|
|
664
|
+
for (const queue = [...usedLayouts]; queue.length; ) {
|
|
665
|
+
const lp = defPath(layoutsDir, queue.pop());
|
|
666
|
+
if (!lp) continue;
|
|
667
|
+
let body;
|
|
668
|
+
try { body = readDef(lp); } catch { continue; /* linted as its own file */ }
|
|
669
|
+
for (const next of includesIn(body, new Set()))
|
|
670
|
+
if (!usedLayouts.has(next)) { usedLayouts.add(next); queue.push(next); }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (existsSync(layoutsDir))
|
|
674
|
+
for (const lf of readdirSync(layoutsDir).filter(isDefFile)) {
|
|
675
|
+
const lname = defName(lf);
|
|
676
|
+
if (!usedLayouts.has(lname))
|
|
677
|
+
report("warn", file, `layouts/${lf} is not referenced by any Switch case. An orphan face is unreachable (add a case "${lname}" or delete the file) (docs.unoverse.ai/design/components)`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// v2 STRAGGLER NUDGE: inside a component that declares a tree, every
|
|
681
|
+
// public-axis write must use `view` — a leftover setValue on
|
|
682
|
+
// "defaultState" still works through the alias but dies with it.
|
|
683
|
+
if (viewTree) {
|
|
684
|
+
for (const sub of ["layouts", "components"]) {
|
|
685
|
+
const d = join(root, sub);
|
|
686
|
+
if (!existsSync(d)) continue;
|
|
687
|
+
for (const lf of readdirSync(d).filter(isDefFile)) {
|
|
688
|
+
try {
|
|
689
|
+
if (/"key":"defaultState"/.test(JSON.stringify(readDef(join(d, lf)))))
|
|
690
|
+
report("warn", file, `${sub}/${lf} writes setValue "defaultState" but this component declares a tree — write "state" (the ruled word; "view"/"defaultState" are legacy) (STATE_MODEL §2)`);
|
|
691
|
+
} catch { /* that file lints separately */ }
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// arrival state: the manifest is the render-contract home (default "inline"); the
|
|
696
|
+
// state block is the legacy fallback.
|
|
697
|
+
let mDefault;
|
|
698
|
+
try {
|
|
699
|
+
const mp = defPath(root, "manifest");
|
|
700
|
+
if (mp) mDefault = readDef(mp).defaultState;
|
|
701
|
+
} catch { /* linted separately */ }
|
|
702
|
+
const arrival = viewTree
|
|
703
|
+
? (viewTree.initial ?? Object.keys(viewTree.states)[0])
|
|
704
|
+
: (mDefault ?? (hasStateBlock ? json.state.defaultState : undefined));
|
|
705
|
+
if (typeof arrival !== "string")
|
|
706
|
+
report("error", file, `a faced component must declare its base state — a v2 state.view tree \`initial\`, or (legacy) manifest.defaultState / state.defaultState (docs.unoverse.ai/design/components)`);
|
|
707
|
+
}
|
|
708
|
+
// A tree DECLARES its order, so an authored stateOrder beside one is a second source
|
|
709
|
+
// of truth that can silently disagree with it. Five docs call this form legacy and
|
|
710
|
+
// nothing enforced it, so a component kept running on one and never heard a word.
|
|
711
|
+
// The old check only fired when a states/ FOLDER existed, which a fragment-based
|
|
712
|
+
// component does not have (docs/doc-control §Guards: enforce rules as tests).
|
|
713
|
+
if (viewTree && json.stateOrder !== undefined)
|
|
714
|
+
report("error", file, `authored "stateOrder" beside a "state.view" tree. The tree declares the order: nest these as substates and delete the list (docs.unoverse.ai/design/state)`);
|
|
715
|
+
|
|
716
|
+
if (stateFiles.length) {
|
|
717
|
+
const order = Array.isArray(json.stateOrder) ? [...json.stateOrder].sort() : null;
|
|
718
|
+
if (!order || !order.length)
|
|
719
|
+
report("error", file, `has states/ but no stateOrder in the envelope (docs.unoverse.ai/design/components)`);
|
|
720
|
+
else if (JSON.stringify(order) !== JSON.stringify(stateFiles))
|
|
721
|
+
report("error", file, `stateOrder and states/*.json must name the same set (docs.unoverse.ai/design/components)`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
checkStateOrder(json.stateOrder, root, file); // non-component envelopes (rare)
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
// bare partial (layouts/ states/ components/ blocks/, or an atom). A template
|
|
729
|
+
// layout's TOP-LEVEL node is the app's layout root — the one non-slot home for
|
|
730
|
+
// `appWidth` (state-owned sizing, docs.unoverse.ai/design/apps).
|
|
731
|
+
walkNode(json, file, root, null, /[\\/]layouts[\\/][^\\/]+\.(json|yaml)$/.test(file) && isAppPath(file));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
return lintFile;
|
|
737
|
+
}
|