@unoverse-platform/studio 0.1.89 → 0.1.91
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 +20 -12
- package/package.json +13 -3
- package/screens/TemplatesLibraryView.tsx +31 -6
- package/screens/TemplatesView.tsx +1 -1
- 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 +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -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 +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -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 +128 -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 +643 -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 +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -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.mjs +64 -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 +107 -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 +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -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 +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -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 +154 -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 +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-node structural walk: primitives, style keys, conditions, dimensions, slots.
|
|
3
|
+
*
|
|
4
|
+
* This is the hot path of the linter. Every node of every definition passes through it,
|
|
5
|
+
* and it is where LAW 1 (tokens only, never a raw px or hex) and the closed style
|
|
6
|
+
* vocabulary are enforced.
|
|
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 { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS, UNIVERSAL_PROPS, PRIMITIVE_PROPS, PRIMITIVE_BINDS } from "./vocabulary.mjs";
|
|
18
|
+
import { isDefFile, defName, defPath, readDef } from "./defs.mjs";
|
|
19
|
+
|
|
20
|
+
// THE FOUR WORDS and the two closed blocks (UNOVERSE_TEMPLATE_MODEL.md §9b + §9e).
|
|
21
|
+
const SECTION_WORDS = ["static", "copywriter", "director"];
|
|
22
|
+
const DELIVERY_KEYS = ["state", "pick", "rules", "preview"];
|
|
23
|
+
// §9e: a queried place says WHAT draws it, WHAT it asks, WHAT KIND may answer, HOW MANY.
|
|
24
|
+
const SEARCH_KEYS = ["component", "type", "include", "pick", "items"];
|
|
25
|
+
// The `object_type`s a place may admit. Apps and skills are absent by construction: the
|
|
26
|
+
// page's own searches are SILENT (no app unlocked, no tool minted, no card lane, no
|
|
27
|
+
// director woken), or every place would rain cards onto a page still assembling itself.
|
|
28
|
+
const QUERY_TYPES = ["services", "images", "needs", "actions"];
|
|
29
|
+
|
|
30
|
+
export function makeWalkNode(ctx) {
|
|
31
|
+
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, defRoot } = ctx;
|
|
32
|
+
// One director per LAYOUT FILE, tracked for this run only (each file is walked once).
|
|
33
|
+
const directorsSeen = new Map();
|
|
34
|
+
|
|
35
|
+
function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
36
|
+
if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
|
|
37
|
+
if (!node || typeof node !== "object") return;
|
|
38
|
+
|
|
39
|
+
// $include — resolves against the DEFINITION ROOT (layouts/ states/ components/…)
|
|
40
|
+
if (typeof node.$include === "string") {
|
|
41
|
+
const a = defPath(root, node.$include);
|
|
42
|
+
const b = join(root, node.$include);
|
|
43
|
+
if (!a && !existsSync(b))
|
|
44
|
+
report("error", file, `$include "${node.$include}" does not resolve under ${relative(process.cwd(), root)}/ (docs.unoverse.ai/design/components)`);
|
|
45
|
+
return; // the included file is linted on its own
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// THE THREE WORDS (UNOVERSE_TEMPLATE_MODEL.md §9b): a template layout's parts say HOW
|
|
49
|
+
// they are filled instead of naming a primitive. They compile to ComponentSlots at
|
|
50
|
+
// serve time, so the linter checks the authored word here and hands the rest of the
|
|
51
|
+
// node to the ordinary walk as the slot it becomes.
|
|
52
|
+
const word = SECTION_WORDS.find((w) => node[w] !== undefined);
|
|
53
|
+
if (word && node.type === undefined) {
|
|
54
|
+
if (!isTemplatePath(file))
|
|
55
|
+
report("error", file, `"${word}:" is template grammar — only a template layout's parts say how they are filled. Apps and components author ComponentSlot directly (UNOVERSE_TEMPLATE_MODEL §9b)`);
|
|
56
|
+
if (node.query !== undefined) {
|
|
57
|
+
report("error", file, `"query:" is retired vocabulary (§9e rule 10b REVERSED 2026-09-02) — spell the place director: with the same component/items/type/include/pick. One word, one judge`);
|
|
58
|
+
}
|
|
59
|
+
if (word === "director") {
|
|
60
|
+
/**
|
|
61
|
+
* ONE WORD, TWO MODES (§9e, 2026-09-02): a director place WITH `component` is a
|
|
62
|
+
* SEARCH place (the page fetches its own content); WITHOUT one it is a DELIVERY
|
|
63
|
+
* band (the conversation fills it). The key sets are mutually exclusive, so a
|
|
64
|
+
* place is always unambiguously one or the other — behaviour never forks on a
|
|
65
|
+
* stray key. The QUESTION is never authored (rule 10d): it is derived from the
|
|
66
|
+
* writing, the page's pool, the component's contract, or the guest's ask.
|
|
67
|
+
*/
|
|
68
|
+
const d = node.director;
|
|
69
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) {
|
|
70
|
+
report("error", file, `"director:" is a block, not a path (§9b rule 1)`);
|
|
71
|
+
} else if (d.limit !== undefined) {
|
|
72
|
+
report("error", file, `director.limit is retired — the one cap word is "pick": how many the director keeps (§9e rule 9, renamed 2026-09-02)`);
|
|
73
|
+
} else if (d.ask !== undefined || d.query !== undefined) {
|
|
74
|
+
report("error", file, `a director place never states its question — it is DERIVED (§9e rule 10d): the writer's headings (items), the page's pool (a gallery), the component's own contract, or the guest's ask`);
|
|
75
|
+
} else if (typeof d.component === "string" && d.component) {
|
|
76
|
+
// A SEARCH PLACE. The checks here are the ones that fail SILENTLY at runtime: a
|
|
77
|
+
// place missing include or pick is skipped, and a kind nobody stores draws
|
|
78
|
+
// nothing — both read as "the design is broken" with no log saying why.
|
|
79
|
+
//
|
|
80
|
+
// THE SEARCH WORDS SIT ON THE THING THAT SEARCHES (owner ruling 2026-09-02): on
|
|
81
|
+
// an items place each ITEM searches, so type/include/pick nest INSIDE items:
|
|
82
|
+
// beside min/max, and pick reads unambiguously as per-item. Spelled flat, the
|
|
83
|
+
// same word meant two scopes depending on whether items: was present.
|
|
84
|
+
const itemed = d.items && typeof d.items === "object" && !Array.isArray(d.items);
|
|
85
|
+
if (d.items !== undefined && !itemed)
|
|
86
|
+
report("error", file, `director.items is a block — items: { min, max, type, include, pick } (§9e rule 10c)`);
|
|
87
|
+
if (itemed && (d.type !== undefined || d.include !== undefined || d.pick !== undefined))
|
|
88
|
+
report("error", file, `an items place nests its search words INSIDE items: { min, max, type, include, pick } — each ITEM searches, so pick reads per item (§9e rule 10c, 2026-09-02)`);
|
|
89
|
+
const TOP_KEYS = itemed ? ["component", "items"] : SEARCH_KEYS;
|
|
90
|
+
for (const k of Object.keys(d))
|
|
91
|
+
if (!TOP_KEYS.includes(k))
|
|
92
|
+
report("error", file, `a search place has no "${k}". It reads ${TOP_KEYS.map((x) => `"${x}"`).join(", ")} (§9e; "state"/"rules" belong to a delivery band — a place with a component is governed by the page's brief)`);
|
|
93
|
+
if (!defPath(root, d.component) && !existsSync(join(root, d.component)))
|
|
94
|
+
report("error", file, `director.component "${d.component}" does not resolve under ${relative(process.cwd(), root)}/ (§9e)`);
|
|
95
|
+
const ITEM_KEYS = ["min", "max", "type", "include", "pick"];
|
|
96
|
+
if (itemed) {
|
|
97
|
+
for (const k of Object.keys(d.items))
|
|
98
|
+
if (!ITEM_KEYS.includes(k))
|
|
99
|
+
report("error", file, `director.items has no "${k}". It reads ${ITEM_KEYS.map((x) => `"${x}"`).join(", ")} (§9e rule 10c)`);
|
|
100
|
+
const { min, max } = d.items;
|
|
101
|
+
if (typeof min !== "number" || typeof max !== "number" || min < 1 || max < min)
|
|
102
|
+
report("error", file, `director.items carries { min, max } — how many items the writer may shape, 1 <= min <= max (§9e rule 10c)`);
|
|
103
|
+
}
|
|
104
|
+
// The search words, read where they live: the place itself, or each item.
|
|
105
|
+
const s = itemed ? d.items : d;
|
|
106
|
+
const at = itemed ? "director.items" : "director";
|
|
107
|
+
// REQUIRED, both (2026-09-01, carried over). The engine supplies NO defaults: a
|
|
108
|
+
// default include or pick would be one template's answer written into the platform.
|
|
109
|
+
if (s.include === undefined)
|
|
110
|
+
report("error", file, `${at}.include is REQUIRED on a search place — what KIND may answer; the engine has no default kind (§9e rule 6)`);
|
|
111
|
+
else if (!Array.isArray(s.include) || s.include.some((t) => typeof t !== "string"))
|
|
112
|
+
report("error", file, `${at}.include is an array of result types — ${QUERY_TYPES.map((t) => `"${t}"`).join(", ")} (§9e rule 6)`);
|
|
113
|
+
else
|
|
114
|
+
for (const t of s.include)
|
|
115
|
+
if (!QUERY_TYPES.includes(String(t)))
|
|
116
|
+
report("error", file, `${at}.include has no "${t}". A place may admit ${QUERY_TYPES.map((x) => `"${x}"`).join(", ")}; apps and skills are never included, because the page's own searches are SILENT (§9e rule 6)`);
|
|
117
|
+
if (s.pick === undefined)
|
|
118
|
+
report("error", file, `${at}.pick is REQUIRED — how many the director keeps${itemed ? " PER ITEM" : ""}; the engine has no default count (§9e rule 9)`);
|
|
119
|
+
else if (typeof s.pick !== "number" || s.pick < 1)
|
|
120
|
+
report("error", file, `${at}.pick is a NUMBER — how many the director keeps${itemed ? " per item" : ""}, a cap the machine enforces (§9e rule 9)`);
|
|
121
|
+
// `type` is OPTIONAL since the question derives (rule 10d): authored it stands;
|
|
122
|
+
// absent, a contract subject searches as intent and the guest's ask as discovery.
|
|
123
|
+
if (s.type !== undefined && s.type !== "discovery" && s.type !== "intent")
|
|
124
|
+
report("error", file, `${at}.type is "discovery" (what lives around a topic) or "intent" (a named need) — or omitted, and the derivation decides (§9e rule 10d)`);
|
|
125
|
+
} else {
|
|
126
|
+
// A DELIVERY BAND.
|
|
127
|
+
for (const k of Object.keys(d))
|
|
128
|
+
if (!DELIVERY_KEYS.includes(k))
|
|
129
|
+
report("error", file, `a delivery band has no "${k}". It reads ${DELIVERY_KEYS.map((x) => `"${x}"`).join(", ")} (§9b; a place that names a component is a search place)`);
|
|
130
|
+
if (d.pick !== undefined && (typeof d.pick !== "number" || d.pick < 1))
|
|
131
|
+
report("error", file, `director.pick is a NUMBER — how many the director keeps, a cap the machine enforces (§9e rule 9)`);
|
|
132
|
+
if (d.rules !== undefined && typeof d.rules !== "string")
|
|
133
|
+
report("error", file, `director.rules is written like a brief: one string of English (§9b)`);
|
|
134
|
+
if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
|
|
135
|
+
report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
|
|
136
|
+
else if (Array.isArray(d.preview)) {
|
|
137
|
+
const comps = componentNamesForFile(file);
|
|
138
|
+
for (const c of d.preview)
|
|
139
|
+
if (comps && !comps.has(String(c).toLowerCase()))
|
|
140
|
+
report("error", file, `director.preview names unknown component "${c}". No match in the design system or this org's components (lookup is case-insensitive)`);
|
|
141
|
+
}
|
|
142
|
+
// A PLACE NEEDS NO NAME (§9d): the compiler addresses delivery bands by
|
|
143
|
+
// position; an AUTHORED state: still stands (the flat-email pin), so two bands
|
|
144
|
+
// pinned to the SAME face remains the one genuine ambiguity.
|
|
145
|
+
const seen = directorsSeen.get(file) ?? [];
|
|
146
|
+
const face = typeof d?.state === "string" ? d.state : null;
|
|
147
|
+
if (face && seen.includes(face))
|
|
148
|
+
report("error", file, `two directed bands claim the face "${face}". A face has exactly one surface, or the active one is ambiguous (§9b rule 3, the reaction contract)`);
|
|
149
|
+
directorsSeen.set(file, [...seen, face]);
|
|
150
|
+
}
|
|
151
|
+
} else if (typeof node[word] !== "string") {
|
|
152
|
+
report("error", file, `"${word}:" links a component by PATH (${word}: components/<part>) — that is what makes it a known part (§9b rule 1)`);
|
|
153
|
+
} else {
|
|
154
|
+
const path = node[word];
|
|
155
|
+
if (!defPath(root, path) && !existsSync(join(root, path)))
|
|
156
|
+
report("error", file, `${word} "${path}" does not resolve under ${relative(process.cwd(), root)}/ (§9b rule 1)`);
|
|
157
|
+
}
|
|
158
|
+
// Everything else on the line rides through to the slot it compiles into. The select
|
|
159
|
+
// is SYNTHESIZED here exactly as the compiler builds it (a linked part claims its own
|
|
160
|
+
// face, limit 1; the director's claim is its `state:` pin) so the rest of the walk
|
|
161
|
+
// sees what will actually be served — and an authored one is the ceremony §9b rule 9
|
|
162
|
+
// exists to abolish.
|
|
163
|
+
if (node.select !== undefined)
|
|
164
|
+
report("error", file, `a template part never authors "select" — the three words compile to the wire primitives at serve time (§9b rule 9)`);
|
|
165
|
+
const { static: _s, copywriter: _c, director: _d, query: _q, select: _sel, ...rest } = node;
|
|
166
|
+
// An unpinned director claims the OWNING STATE's name (arrivals keep their own state
|
|
167
|
+
// and the template's states react by name — the free grid↔page dance), so the face
|
|
168
|
+
// comes from whichever state in the envelope declares this layout.
|
|
169
|
+
const owningState = () => {
|
|
170
|
+
const env = defPath(root, basename(root));
|
|
171
|
+
const states = env ? readDef(env)?.states : undefined;
|
|
172
|
+
if (!states || typeof states !== "object") return undefined;
|
|
173
|
+
const here = defName(basename(file));
|
|
174
|
+
return Object.keys(states).find((s) => {
|
|
175
|
+
const l = states[s]?.layout;
|
|
176
|
+
return typeof l === "string" && basename(l).toLowerCase() === here.toLowerCase();
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
// A QUERIED place links its component under `component:`, and claims that part's own
|
|
180
|
+
// face at limit 1 exactly as a linked part does — its `limit:` counts ROWS inside the
|
|
181
|
+
// part, not instances of it, so it never reaches the slot (§9e).
|
|
182
|
+
const linkedPath = word === "query" ? node.query?.component : node[word];
|
|
183
|
+
const face =
|
|
184
|
+
word === "director" ? (node.director?.state ?? owningState()) : typeof linkedPath === "string" ? linkedPath.split("/").pop() : undefined;
|
|
185
|
+
const limit = word === "director" ? node.director?.limit : 1;
|
|
186
|
+
const select = { from: "all", ...(face ? { where: { field: "state", eq: face } } : {}), ...(typeof limit === "number" ? { limit } : {}) };
|
|
187
|
+
return walkNode({ type: "ComponentSlot", select, ...rest }, file, root, widthCap);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const t = node.type;
|
|
191
|
+
if (typeof t !== "string")
|
|
192
|
+
report("error", file, `node without "type" (and no $include). Every node names a primitive (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
193
|
+
else if (!PRIMITIVES.has(t))
|
|
194
|
+
report("error", file, `unknown primitive "${t}". The set is closed; compose, don't invent (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
195
|
+
|
|
196
|
+
// WHAT THIS PRIMITIVE IS (UNOVERSE_CONFORMANCE §2a). Before this existed, every node
|
|
197
|
+
// shared one bag of 36 optional properties, so a wrong field name was accepted by the
|
|
198
|
+
// schema AND the lint and only showed up as a blank on screen. A wrong name is never a
|
|
199
|
+
// judgment call, so this is an error rather than a warning.
|
|
200
|
+
else if (PRIMITIVE_PROPS[t]) {
|
|
201
|
+
const own = PRIMITIVE_PROPS[t];
|
|
202
|
+
for (const k of Object.keys(node))
|
|
203
|
+
if (!UNIVERSAL_PROPS.has(k) && !own.includes(k))
|
|
204
|
+
report("error", file, `${t} has no "${k}". It reads ${own.length ? own.map((o) => `"${o}"` ).join(", ") : "no properties of its own"} (docs.unoverse.ai/reference/primitives)`);
|
|
205
|
+
|
|
206
|
+
// `bind` maps a target onto a data field, and only some targets are read. Binding a
|
|
207
|
+
// target the view never looks at is the silent failure this whole rule exists for:
|
|
208
|
+
// the field streams in correctly and the element keeps drawing its preview default.
|
|
209
|
+
const reads = PRIMITIVE_BINDS[t];
|
|
210
|
+
if (reads && node.bind && typeof node.bind === "object" && !Array.isArray(node.bind))
|
|
211
|
+
for (const k of Object.keys(node.bind))
|
|
212
|
+
if (!reads.includes(k))
|
|
213
|
+
report("error", file, `${t} never reads "bind.${k}", so the value would arrive and render nothing. It reads ${reads.map((r) => `"bind.${r}"`).join(", ")} (docs.unoverse.ai/reference/primitives)`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (t === "Switch") {
|
|
217
|
+
if (typeof node.on !== "string" || !node.cases || typeof node.cases !== "object")
|
|
218
|
+
report("error", file, `Switch needs "on" (the discriminant field) + "cases" (docs.unoverse.ai/design/state)`);
|
|
219
|
+
else
|
|
220
|
+
for (const [caseKey, branch] of Object.entries(node.cases)) {
|
|
221
|
+
const vw = branch && typeof branch === "object" ? branch.visibleWhen : undefined;
|
|
222
|
+
const guarded = typeof vw === "string" ? vw : vw && typeof vw === "object" ? vw.field : null;
|
|
223
|
+
if (guarded === node.on)
|
|
224
|
+
report("error", file, `Switch on "${node.on}" → case "${caseKey}" re-guards its own discriminant. A layer never guards itself; delete the visibleWhen (docs.unoverse.ai/design/components)`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Each: an `item` + a list — EITHER a literal `items:[]` (hardcoded content,
|
|
228
|
+
// the microapp default) OR `bind.items` (a workflow-fed array).
|
|
229
|
+
if (t === "Each") {
|
|
230
|
+
const hasList = Array.isArray(node.items) || (node.bind && typeof node.bind === "object" && node.bind.items);
|
|
231
|
+
if (!node.item || !hasList)
|
|
232
|
+
report("error", file, `Each needs "item" + a list. Literal "items": [...] or "bind": { "items": "<field>" } (docs.unoverse.ai/design/components)`);
|
|
233
|
+
}
|
|
234
|
+
if (t === "Ref") {
|
|
235
|
+
if (typeof node.ref !== "string")
|
|
236
|
+
report("error", file, `Ref needs "ref": "<atom name>" (docs.unoverse.ai/design/components)`);
|
|
237
|
+
else if (atomsDirExists && !refResolves(node.ref, file))
|
|
238
|
+
report("error", file, `Ref "${node.ref}". No matching atom, shared component, or own-org component (bare, or "<org>/<name>" for the file's OWN org only — org-privacy); lookup is case-insensitive by name`);
|
|
239
|
+
// RESOLVES IS NOT ENOUGH. Ref lookup ignores case; the marketplace fetches
|
|
240
|
+
// items/<kind>/<key>.json off a case-sensitive host. A Ref that differs only in case
|
|
241
|
+
// renders forever in design/ and 404s on install (index.mjs, canonicalRef).
|
|
242
|
+
else if (canonicalRef) {
|
|
243
|
+
const exact = canonicalRef(node.ref);
|
|
244
|
+
if (exact && exact !== node.ref)
|
|
245
|
+
report("warn", file, `Ref "${node.ref}" should be "${exact}". It resolves here because lookup ignores case, and fails on install because the marketplace does not`);
|
|
246
|
+
}
|
|
247
|
+
// A KEY THE ATOM DOES NOT DECLARE MATCHES NOTHING, IN SILENCE.
|
|
248
|
+
//
|
|
249
|
+
// `props` remaps the atom's own field names onto the host's and `with` supplies
|
|
250
|
+
// literals for them: both are looked up BY NAME against the atom's `props` block, and
|
|
251
|
+
// the expander ignores anything it cannot find. So a wrong name is not a broken tree,
|
|
252
|
+
// it is a perfect-looking element wired to nothing.
|
|
253
|
+
//
|
|
254
|
+
// It has cost real screens. `form-toggle` declares `on` and `description`; a form wrote
|
|
255
|
+
// `props: { value: … }` with `with: { help: … }` and got a switch bound to no field and
|
|
256
|
+
// a sub-line that never appeared. Every node was structurally valid, every other rule
|
|
257
|
+
// passed, and the control simply did not move when clicked.
|
|
258
|
+
if (typeof node.ref === "string" && declaredProps) {
|
|
259
|
+
const declared = declaredProps(node.ref);
|
|
260
|
+
// null = not an atom (a shared component declares no prop contract) or unreadable.
|
|
261
|
+
// Never invent a contract the author would then have to satisfy.
|
|
262
|
+
if (declared) {
|
|
263
|
+
for (const [key, source] of [["props", node.props], ["with", node.with]]) {
|
|
264
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) continue;
|
|
265
|
+
for (const name of Object.keys(source)) {
|
|
266
|
+
if (declared.has(name)) continue;
|
|
267
|
+
const known = [...declared].sort().join(", ");
|
|
268
|
+
report(
|
|
269
|
+
"error",
|
|
270
|
+
file,
|
|
271
|
+
`Ref "${node.ref}" ${key === "props" ? "remaps" : "passes"} "${name}", which the atom does not declare. ` +
|
|
272
|
+
`Both \`props\` and \`with\` are matched BY NAME against the atom's own props, and an unknown key is ` +
|
|
273
|
+
`silently ignored: the element renders and does nothing. ${node.ref} declares: ${known} (docs.unoverse.ai/design/components)`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Icon glyphs are SERVED (theme.icons ← styles/semantic/icons), and the SDK draws
|
|
281
|
+
// nothing when the name misses — a blank where a glyph was meant to be, no error.
|
|
282
|
+
if (t === "Icon" && typeof node.icon === "string" && !node.icon.includes("{{"))
|
|
283
|
+
checkToken(file, t, "icon", node.icon);
|
|
284
|
+
if (t === "ComponentSlot") {
|
|
285
|
+
if (!node.select || typeof node.select !== "object")
|
|
286
|
+
report("error", file, `ComponentSlot needs "select" ({} for the conversation flow) (docs.unoverse.ai/design/apps)`);
|
|
287
|
+
else if (node.select.from === "all" && !node.select.type && !node.select.where)
|
|
288
|
+
report("warn", file, `global ComponentSlot (from:"all") with no "type" and no "where". Selects OLDEST-first; a trap in a multi-turn surface. Filter by "where" (the reaction contract, §5b) or pin "type", unless the shell is deliberately catch-all (docs.unoverse.ai/design/apps)`);
|
|
289
|
+
// STATE-SELECTED UI (STATE_MODEL §5): a reaction surface reacts to the component's
|
|
290
|
+
// PUBLIC state — the `view` axis (`defaultState` = legacy alias) — never to a
|
|
291
|
+
// component's internal state (step/phase/…) which is private to the component.
|
|
292
|
+
// Selecting on any other field reaches past the boundary the whole model rests on.
|
|
293
|
+
else if (node.select.where && node.select.where.field && node.select.where.field !== "state" && node.select.where.field !== "view" && node.select.where.field !== "defaultState")
|
|
294
|
+
report("warn", file, `reaction surface selects on "${node.select.where.field}". A template reacts to a component's PUBLIC state ("state"; legacy "view"/"defaultState"), never its internal state (that is private to the component) (STATE_MODEL §5)`);
|
|
295
|
+
// ONE STATE AT A TIME: the template is in exactly one state, so a surface must
|
|
296
|
+
// claim exactly ONE view by `eq` — `ne`/`in`/bare selects make "which state is
|
|
297
|
+
// the template in?" ambiguous.
|
|
298
|
+
else if (node.select.where && (node.select.where.field === "state" || node.select.where.field === "view" || node.select.where.field === "defaultState") && typeof node.select.where.eq !== "string")
|
|
299
|
+
report("error", file, `a reaction surface claims exactly ONE view: select.where needs "eq": "<view>": ne/in/bare make the template's active state ambiguous (docs.unoverse.ai/design/state)`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// STATE-OWNED SIZING (docs.unoverse.ai/design/apps): every PANEL states its width once via
|
|
303
|
+
// `appWidth`; the app is the sum of the open ones. A plain node = always open (the
|
|
304
|
+
// core chat column); a reaction-surface ComponentSlot = open while occupied; a
|
|
305
|
+
// visibleWhen pane = open while its condition matches.
|
|
306
|
+
if (node.appWidth !== undefined) {
|
|
307
|
+
if (typeof node.appWidth !== "string" || node.appWidth.trim() === "")
|
|
308
|
+
report("error", file, `"appWidth" must be a CSS width string ("360px", "min(50vw, 760px)") or a named app size ("chat", "rail", "panel") (docs.unoverse.ai/design/apps)`);
|
|
309
|
+
// `flex` is contract vocabulary, not a token: the surface takes the REMAINING
|
|
310
|
+
// host space (the SDK reports a full-width app while it is active).
|
|
311
|
+
// Any other bare name is a STANDARD SIZE — it must exist in the org's
|
|
312
|
+
// styles/semantic/app-sizes (or the inherited marketplace set); the server
|
|
313
|
+
// resolves it at serve time, and an unknown name resolves to NOTHING silently.
|
|
314
|
+
else if (node.appWidth !== "flex" && /^[a-z][a-z0-9-]*$/i.test(node.appWidth)) {
|
|
315
|
+
const sizes = appSizesForFile(file);
|
|
316
|
+
if (sizes && !(node.appWidth in sizes))
|
|
317
|
+
report("error", file, `"appWidth": "${node.appWidth}" names no app size. Use "flex", a CSS width, or a name from styles/semantic/app-sizes (known: ${Object.keys(sizes).join(", ") || "none"}) (docs.unoverse.ai/design/apps)`);
|
|
318
|
+
}
|
|
319
|
+
if (t === "ComponentSlot" && !(node.select && node.select.where))
|
|
320
|
+
report("error", file, `"appWidth" on a ComponentSlot without select.where. Only a reaction surface can slide out; the flow slot never sizes the app (docs.unoverse.ai/design/apps)`);
|
|
321
|
+
// ONE declaration per panel: the SDK sizes the frame FROM appWidth (width +
|
|
322
|
+
// flex: 0 0 auto) — a frame width/flex alongside it is dead duplication that
|
|
323
|
+
// can silently disagree.
|
|
324
|
+
if (t === "ComponentSlot" && node.frame && node.frame.style) {
|
|
325
|
+
for (const dup of ["width", "flex"])
|
|
326
|
+
if (node.frame.style[dup] !== undefined)
|
|
327
|
+
report("error", file, `panel frame declares style.${dup} alongside appWidth. The panel states its width ONCE; the SDK sizes the frame from appWidth. Remove the frame ${dup} (docs.unoverse.ai/design/apps)`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// BRIEFED COMPONENTS (the composer): a `brief` sits on the node that renders what it
|
|
332
|
+
// describes and compiles into the component's MCP tool schema (server briefSchema →
|
|
333
|
+
// registry metadata.inputSchema → the minted tool). Shape is a closed contract —
|
|
334
|
+
// a typo'd key or misplaced constraint silently weakens the compiled schema.
|
|
335
|
+
if (node.brief !== undefined) {
|
|
336
|
+
const b = node.brief;
|
|
337
|
+
if (typeof b !== "string" && (typeof b !== "object" || b === null || Array.isArray(b)))
|
|
338
|
+
report("error", file, `"brief" must be a string (the description) or { description, maxLength | minItems/maxItems } (docs.unoverse.ai/design/components)`);
|
|
339
|
+
else if (typeof b === "object") {
|
|
340
|
+
// description/maxLength/minItems/maxItems COMPILE into the tool inputSchema. `hydrate` is a
|
|
341
|
+
// NON-schema brief annotation (a hydration hook naming what to hydrate) — a valid brief key
|
|
342
|
+
// that does NOT compile into the schema; allowed here so it doesn't read as a typo.
|
|
343
|
+
// `optional` marks a field the model may omit: a brief saying "empty when the source
|
|
344
|
+
// gives none" must not compile to a required, minLength-1 string, or the model has to
|
|
345
|
+
// invent a value to satisfy the schema.
|
|
346
|
+
const BRIEF_KEYS = new Set(["description", "maxLength", "minItems", "maxItems", "hydrate", "optional"]);
|
|
347
|
+
for (const k of Object.keys(b))
|
|
348
|
+
if (!BRIEF_KEYS.has(k))
|
|
349
|
+
report("error", file, `brief.${k} is not part of the brief contract. Only description / maxLength / minItems / maxItems / optional (schema) or hydrate (hydration hook) are allowed (docs.unoverse.ai/design/components)`);
|
|
350
|
+
if (b.description !== undefined && typeof b.description !== "string")
|
|
351
|
+
report("error", file, `brief.description must be a string. It IS the schema field's description (docs.unoverse.ai/design/components)`);
|
|
352
|
+
if (b.optional !== undefined && typeof b.optional !== "boolean")
|
|
353
|
+
report("error", file, `brief.optional must be a boolean (docs.unoverse.ai/design/components)`);
|
|
354
|
+
if (b.hydrate !== undefined && typeof b.hydrate !== "string")
|
|
355
|
+
report("error", file, `brief.hydrate must be a string (names the field/source to hydrate) (docs.unoverse.ai/design/components)`);
|
|
356
|
+
for (const nk of ["maxLength", "minItems", "maxItems"])
|
|
357
|
+
if (b[nk] !== undefined && (typeof b[nk] !== "number" || b[nk] < 0 || !Number.isInteger(b[nk])))
|
|
358
|
+
report("error", file, `brief.${nk} must be a non-negative integer. It compiles to the native JSON Schema keyword (docs.unoverse.ai/design/components)`);
|
|
359
|
+
if (typeof b.minItems === "number" && typeof b.maxItems === "number" && b.minItems > b.maxItems)
|
|
360
|
+
report("error", file, `brief.minItems (${b.minItems}) > maxItems (${b.maxItems}). No composition can satisfy this schema (docs.unoverse.ai/design/components)`);
|
|
361
|
+
const bound = node.bind && (node.bind.value || node.bind.src);
|
|
362
|
+
const isEach = t === "Each" && node.bind && node.bind.items;
|
|
363
|
+
if (b.maxLength !== undefined && !bound)
|
|
364
|
+
report("warn", file, `brief.maxLength on a node with no bind. A length cap only compiles when the brief sits next to the bound field it governs (docs.unoverse.ai/design/components)`);
|
|
365
|
+
if ((b.minItems !== undefined || b.maxItems !== undefined) && !isEach)
|
|
366
|
+
report("warn", file, `brief.minItems/maxItems on a non-Each node. Item counts only compile on the Each that binds the array (docs.unoverse.ai/design/components)`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (node.visibleWhen !== undefined) checkCondition(node.visibleWhen, file, t || "node");
|
|
371
|
+
|
|
372
|
+
// deprecated FOCUS BRIDGE (STATE_MODEL §5b): nothing writes a template `defaultState`
|
|
373
|
+
// key anymore — a component writes only its own slice; templates react via select.where.
|
|
374
|
+
// (Legit panel/draft setAppValue writes a DIFFERENT key and is untouched.)
|
|
375
|
+
//
|
|
376
|
+
const scanAction = (a) => {
|
|
377
|
+
if (!a || typeof a !== "object") return;
|
|
378
|
+
if (a.type === "setAppValue") {
|
|
379
|
+
// WHAT IS DEPRECATED IS THE FOCUS BRIDGE, not the action (2026-08-23, owner
|
|
380
|
+
// ruling). Writing `defaultState` to move the template is the bridge: a component
|
|
381
|
+
// reaching up to change which surface is shown, which the tree reacts to by name
|
|
382
|
+
// instead. Writing the template's own CHROME (openPanel, draft) is legitimate from
|
|
383
|
+
// anywhere — a `Ref`-embedded component has no slice of its own, so template state
|
|
384
|
+
// is the only state in scope, and the shared chat chrome depends on it.
|
|
385
|
+
if (Array.isArray(a.values) && a.values.some((v) => v && v.key === "defaultState"))
|
|
386
|
+
report(isAppPath(file) ? "warn" : "error", file, `setAppValue writing "defaultState" is the deprecated focus bridge. Change the view with setValue and let the template react via ComponentSlot.select.where (STATE_MODEL §5b)`);
|
|
387
|
+
}
|
|
388
|
+
if (a.then) scanAction(a.then);
|
|
389
|
+
};
|
|
390
|
+
if (node.action) scanAction(node.action);
|
|
391
|
+
|
|
392
|
+
// style — closed KEY vocabulary + real space-scale VALUES (both cross-platform contracts)
|
|
393
|
+
if (node.style && typeof node.style === "object") {
|
|
394
|
+
// a container query the element's own layout can never satisfy — the threshold is
|
|
395
|
+
// at/above an ancestor's maxWidth cap, so visibility is decided by the HOST surface,
|
|
396
|
+
// not the design (shows in a wide studio stage, vanishes in a chat column)
|
|
397
|
+
const mw = node.style.maxWidth;
|
|
398
|
+
if (typeof mw === "string" && /^\d+$/.test(mw))
|
|
399
|
+
widthCap = widthCap == null ? Number(mw) : Math.min(widthCap, Number(mw));
|
|
400
|
+
const hb = node.style.hideBelow;
|
|
401
|
+
if (typeof hb === "string" && /^\d+$/.test(hb) && widthCap != null && Number(hb) >= widthCap)
|
|
402
|
+
report("warn", file, `hideBelow "${hb}" ≥ an ancestor maxWidth "${widthCap}". The query can only be satisfied by the surrounding surface, so visibility depends on the host, not the card; lower the threshold below the card's own max width (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
403
|
+
const checkKeys = (obj, where) => {
|
|
404
|
+
for (const k of Object.keys(obj)) {
|
|
405
|
+
if (k === "when") continue; // validated below
|
|
406
|
+
if (!STYLE_KEYS.has(k))
|
|
407
|
+
report("error", file, `${where}: unknown style key "${k}". The style vocabulary is closed (the cross-platform contract). Typo, or a web-ism that won't port (docs.unoverse.ai/design/styles-and-tokens)`);
|
|
408
|
+
else if ((k === "hover" || k === "active") && obj[k] && typeof obj[k] === "object")
|
|
409
|
+
checkKeys(obj[k], `${where}.${k}`);
|
|
410
|
+
else if (DIMENSION_KEYS.has(k)) checkDimension(file, where, k, obj[k]);
|
|
411
|
+
// Every OTHER value the interpreter resolves against the theme: a name that is
|
|
412
|
+
// not a token is not an error anywhere downstream, it is a dropped declaration.
|
|
413
|
+
else checkToken(file, where, k, obj[k]);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
checkKeys(node.style, `${t}.style`);
|
|
417
|
+
// style.when = [{ field, eq|ne|in?, apply:{…} }, …]
|
|
418
|
+
if (node.style.when !== undefined) {
|
|
419
|
+
const w = node.style.when;
|
|
420
|
+
if (!Array.isArray(w))
|
|
421
|
+
report("error", file, `${t}.style.when must be an array of { field, eq|ne|in, apply } entries (docs.unoverse.ai/design/state)`);
|
|
422
|
+
else
|
|
423
|
+
for (const e of w) {
|
|
424
|
+
if (!e || typeof e !== "object" || typeof e.field !== "string" || !e.apply)
|
|
425
|
+
report("error", file, `${t}.style.when entry needs "field" + "apply" (docs.unoverse.ai/design/state)`);
|
|
426
|
+
else {
|
|
427
|
+
const extra = Object.keys(e).filter((k) => !CONDITION_KEYS.has(k) && k !== "apply");
|
|
428
|
+
if (extra.length)
|
|
429
|
+
report("error", file, `${t}.style.when: illegal key(s) ${extra.join(", ")}. Conditions are eq/ne/in/truthy only (docs.unoverse.ai/design/state)`);
|
|
430
|
+
if (typeof e.apply === "object") checkKeys(e.apply, `${t}.style.when.apply`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const key of CHILD_NODE_KEYS) if (node[key] !== undefined) walkNode(node[key], file, root, widthCap);
|
|
437
|
+
if (t === "Switch" && node.cases && typeof node.cases === "object")
|
|
438
|
+
for (const branch of Object.values(node.cases)) walkNode(branch, file, root, widthCap);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── stateOrder ⇄ states/ folder cross-check ──
|
|
442
|
+
|
|
443
|
+
return walkNode;
|
|
444
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The linters. TWO of them, because there are two things to validate and they share
|
|
3
|
+
* almost nothing.
|
|
4
|
+
*
|
|
5
|
+
* nodes/ node manifests: JSON Schema per part, plus the cross-file rules a schema
|
|
6
|
+
* cannot express (events matching output connectors, calls reachable by name,
|
|
7
|
+
* credentials declared in the package that needs them)
|
|
8
|
+
* design/ definitions: a CLOSED primitive set, a closed style vocabulary, token-only
|
|
9
|
+
* values, Switch discriminants, layouts reachable from a case
|
|
10
|
+
*
|
|
11
|
+
* They share `context.mjs` and nothing else. One reports on `api/run.yaml`, the other on
|
|
12
|
+
* whether a colour is a raw hex. Merging them would produce a linter that is worse at both.
|
|
13
|
+
*
|
|
14
|
+
* WHY THEY LIVE HERE rather than in scripts/. The same rules are needed by four callers,
|
|
15
|
+
* and only one of them is a terminal:
|
|
16
|
+
*
|
|
17
|
+
* `unoverse lint` / `lint nodes` coloured lines (scripts/lib/*.mjs, thin formatters)
|
|
18
|
+
* Studio findings beside the definition being edited
|
|
19
|
+
* POST /publish a refusal, so a broken definition never reaches the DB
|
|
20
|
+
* CI a non-zero exit
|
|
21
|
+
*
|
|
22
|
+
* Publishing without them is the gap LOCAL_STUDIO.md:246 names: "a malformed definition is
|
|
23
|
+
* one restart from a broken server". A published row does not even need the restart.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export { lintNodes, hasErrors as nodesHaveErrors } from "./nodes/index.mjs";
|
|
27
|
+
export { lintDefinitions, hasErrors as definitionsHaveErrors } from "./design/index.mjs";
|
|
28
|
+
export { problems, report, rel, reset } from "./context.mjs";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEMPLATE VOCABULARY a manifest may use: the helpers the platform registers, and the prompt
|
|
3
|
+
* blocks it ships. Both answer the same question — "does this name resolve at run time?" — and both
|
|
4
|
+
* fail the same way if it does not: silently, at run time, in a prompt.
|
|
5
|
+
*
|
|
6
|
+
* ITS OWN MODULE TO BREAK A CYCLE. These lived in `package.mjs` after the linter was split out of
|
|
7
|
+
* scripts/, but the only consumer is `node.mjs`, and `package.mjs` already imports `node.mjs` to
|
|
8
|
+
* lint each node — so importing them back would be circular. Neither is a package-level rule
|
|
9
|
+
* anyway: they are facts about the template language, shared by every node in every package.
|
|
10
|
+
*/
|
|
11
|
+
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { rel, state } from "../context.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Handlebars built-ins plus the helpers the platform registers in
|
|
17
|
+
* engine/src/template/StringTemplateResolver.ts. Anything else compiles fine and
|
|
18
|
+
* then throws when the node actually runs, which is the worst place to find out.
|
|
19
|
+
*/
|
|
20
|
+
export const HANDLEBARS_HELPERS = new Set([
|
|
21
|
+
"if", "unless", "each", "with", "lookup", "log", "else", // built-in
|
|
22
|
+
"toJSON", "filter", "eq", "contains", // registered
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The prompt-block library: prompts/blocks/**\/*.md, keyed the way the resolver keys them
|
|
27
|
+
* (engine/src/template/promptBlocks.ts) — the filename camelCased, so markdown-guidelines.md is
|
|
28
|
+
* referenced as {{prompt.markdownGuidelines}}.
|
|
29
|
+
*
|
|
30
|
+
* A manifest must never hold a COPY of a block's words: that is a fork that silently stops tracking
|
|
31
|
+
* the block. It references, and this checks the reference resolves.
|
|
32
|
+
*
|
|
33
|
+
* COMPUTED PER RUN, not once at import. As a CLI this was a module-level IIFE over a `NODES_HOME`
|
|
34
|
+
* constant; the split left that constant behind and the IIFE ran at import time, so the whole linter
|
|
35
|
+
* threw `NODES_HOME is not defined` before `lintNodes` was even called. A constant is also wrong on
|
|
36
|
+
* its own terms now: Studio, /publish and CI each lint their own tree, and a value frozen at import
|
|
37
|
+
* would describe whichever tree loaded first. `state.nodesHome` is set by `reset()` every run.
|
|
38
|
+
*
|
|
39
|
+
* Memoized per home, because one lint walks many packages and the library is the same for all.
|
|
40
|
+
*/
|
|
41
|
+
const CACHE = new Map();
|
|
42
|
+
|
|
43
|
+
export function promptBlocks() {
|
|
44
|
+
const home = resolve(state.nodesHome, "../prompts/blocks");
|
|
45
|
+
const cached = CACHE.get(home);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
|
|
48
|
+
const found = new Map();
|
|
49
|
+
const walk = (d) => {
|
|
50
|
+
if (!existsSync(d)) return;
|
|
51
|
+
for (const e of readdirSync(d)) {
|
|
52
|
+
const p = join(d, e);
|
|
53
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
54
|
+
else if (e.endsWith(".md")) {
|
|
55
|
+
const id = e.replace(/\.md$/, "");
|
|
56
|
+
found.set(id.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), rel(p));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(home);
|
|
61
|
+
CACHE.set(home, found);
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative node linter, as a LIBRARY. Rules in, findings out.
|
|
3
|
+
*
|
|
4
|
+
* It prints nothing and exits nothing, because four callers need the same rules and each
|
|
5
|
+
* presents them differently:
|
|
6
|
+
*
|
|
7
|
+
* `unoverse lint nodes` coloured lines in a terminal (scripts/lib/lint-nodes.mjs)
|
|
8
|
+
* Studio findings beside the definition you are editing
|
|
9
|
+
* POST /publish a refusal, so a broken manifest never reaches the database
|
|
10
|
+
* CI a non-zero exit
|
|
11
|
+
*
|
|
12
|
+
* It lived in scripts/ as a CLI, which meant the universe could not use it. Publishing
|
|
13
|
+
* without it is the gap LOCAL_STUDIO.md:246 names: "a malformed definition is one restart
|
|
14
|
+
* from a broken server", and a published row does not even need the restart.
|
|
15
|
+
*
|
|
16
|
+
* FOUR TIERS (docs/architecture/DECLARATIVE_NODES.md §6). This is tiers 2 and 3.
|
|
17
|
+
* 1 editor $schema pointers + .vscode yaml.schemas, no tool
|
|
18
|
+
* 2 structural every part validated against nodes/_schema/*.json
|
|
19
|
+
* 3 semantic the cross-file rules a schema cannot express
|
|
20
|
+
* 4 dry run `unoverse node test`, executes against testData, not here
|
|
21
|
+
*/
|
|
22
|
+
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
24
|
+
import { problems, derivedKinds, reset, report } from "../context.mjs";
|
|
25
|
+
import { loadSchemas } from "./schema.mjs";
|
|
26
|
+
import { lintPackage } from "./package.mjs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lint every node package under `nodesHome`.
|
|
30
|
+
*
|
|
31
|
+
* Returns findings rather than printing them, and NEVER throws for a lint failure: a
|
|
32
|
+
* missing directory or unreadable schema is itself reported, so a caller gets one shape
|
|
33
|
+
* of answer whatever went wrong.
|
|
34
|
+
*/
|
|
35
|
+
export function lintNodes(nodesHome) {
|
|
36
|
+
const home = resolve(nodesHome);
|
|
37
|
+
reset(home, join(home, "_schema"));
|
|
38
|
+
|
|
39
|
+
if (!existsSync(home)) {
|
|
40
|
+
report("error", home, "no nodes directory here");
|
|
41
|
+
return { problems: [...problems], derivedKinds: new Map(), nodeCount: 0, pkgCount: 0, nodesHome: home };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
loadSchemas();
|
|
45
|
+
|
|
46
|
+
let nodeCount = 0,
|
|
47
|
+
pkgCount = 0;
|
|
48
|
+
for (const entry of readdirSync(home)) {
|
|
49
|
+
const dir = join(home, entry);
|
|
50
|
+
if (entry.startsWith("_") || entry.startsWith(".") || !statSync(dir).isDirectory()) continue;
|
|
51
|
+
if (!existsSync(join(dir, "nodes")) && !existsSync(join(dir, "package.yaml"))) continue; // pure code package
|
|
52
|
+
pkgCount++;
|
|
53
|
+
nodeCount += lintPackage(dir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rank = { error: 0, warn: 1, hint: 2 };
|
|
57
|
+
const sorted = [...problems].sort((a, b) => rank[a.level] - rank[b.level] || a.file.localeCompare(b.file));
|
|
58
|
+
return { problems: sorted, derivedKinds: new Map(derivedKinds), nodeCount, pkgCount, nodesHome: home };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when anything would fail a build. Warnings and hints inform, errors stop. */
|
|
62
|
+
export function hasErrors(result) {
|
|
63
|
+
return result.problems.some((p) => p.level === "error");
|
|
64
|
+
}
|