@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.
Files changed (132) hide show
  1. package/local/plugin.ts +21 -65
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +155 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +659 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. package/local/mcp.ts +0 -265
@@ -0,0 +1,996 @@
1
+ /**
2
+ * The design definitions linter, as a LIBRARY. Rules in, findings out.
3
+ *
4
+ * Sibling of ../nodes/ and deliberately SEPARATE: this one knows the closed primitive set,
5
+ * the closed style vocabulary, token-only values and Switch discriminants. Merging the two
6
+ * would give one linter that is worse at both.
7
+ *
8
+ * DEPENDENCY-FREE, still: JSON and YAML frontmatter are parsed by hand rather than pulling
9
+ * `yaml` in. Worth keeping now that it sits beside a package that does have dependencies.
10
+ *
11
+ * THE WHOLE BODY IS INSIDE `lintDefinitions`, on purpose. It was a script whose top-level
12
+ * constants derived from the design root, and two bare blocks that RAN RULES at import time.
13
+ * As a library that is wrong twice: findings would accumulate between runs, and rules would
14
+ * fire before anyone asked. Function scope makes both impossible rather than merely unlikely.
15
+ *
16
+ * Callers: `unoverse lint` (a thin formatter), Studio, POST /publish, CI.
17
+ */
18
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
19
+ import { join, relative, dirname, basename, resolve, sep } from "node:path";
20
+ import { createRequire } from "node:module";
21
+ import { DEF_EXTS, isDefFile, defName, defPath, parseDef, readDef } from "./defs.mjs";
22
+ import { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS, DIMENSION_LITERALS, TOKEN_KEYS, LITERAL_VALUES } from "./vocabulary.mjs";
23
+ import { makeWalkNode } from "./walk.mjs";
24
+ import { makeLintFile } from "./file.mjs";
25
+ import { makeTokensForFile } from "./tokens.mjs";
26
+
27
+ /**
28
+ * Lint every definition under `designRoot`. Prints nothing, exits nothing.
29
+ * A missing design/ folder is REPORTED rather than thrown, so callers get one shape of answer.
30
+ *
31
+ * `options.overlay` maps an absolute path to text that STANDS IN for what is on disk.
32
+ * Studio's editor lints what the developer has typed before it is saved, and the only
33
+ * honest way to do that is to run THESE rules over it. Without the overlay an editor has
34
+ * to approximate the rules, which is a second linter, which is the thing this module's
35
+ * existence rules out. Sibling files are still read from disk: only the file being edited
36
+ * is substituted.
37
+ */
38
+ export function lintDefinitions(designRoot, options = {}) {
39
+ const overlay = {};
40
+ for (const [k, v] of Object.entries(options.overlay ?? {})) overlay[resolve(k)] = v;
41
+ const readText = (f) => {
42
+ const hit = overlay[resolve(f)];
43
+ return hit === undefined ? readFileSync(f, "utf8") : hit;
44
+ };
45
+ const candidates = designRoot ? [resolve(designRoot)] : [resolve("apps/unoverse/design"), resolve("design")];
46
+
47
+ /** The monorepo's shapes: the design system at `design/marketplace/`, or the legacy one
48
+ * with `components/` and `atoms/` loose at the root. */
49
+ const holdsDesignSystem = (p) =>
50
+ existsSync(join(p, "marketplace")) || existsSync(join(p, "components")) || existsSync(join(p, "atoms"));
51
+
52
+ /**
53
+ * A DEVELOPER'S design/ HOLDS ONLY ORG FOLDERS, and looking for the monorepo's shapes there
54
+ * finds nothing. `design/<org>/{components,styles,apps}` is what Studio scaffolds and
55
+ * the only layout a developer ever has, because the design system is INSTALLED rather
56
+ * than authored (sync-starter.sh keeps `design/marketplace` out of a project on purpose).
57
+ *
58
+ * So a project could not be linted, and since publishing lints first, it could not be
59
+ * published either: "no design/ folder here", naming the folder it was standing in.
60
+ */
61
+ const holdsOrgs = (p) => {
62
+ if (!existsSync(p)) return false;
63
+ return readdirSync(p)
64
+ .filter((e) => !e.startsWith("."))
65
+ .map((e) => join(p, e))
66
+ .some((d) => {
67
+ try {
68
+ return (
69
+ statSync(d).isDirectory() &&
70
+ ["components", "atoms", "styles", "apps"].some((s) => existsSync(join(d, s)))
71
+ );
72
+ } catch {
73
+ return false;
74
+ }
75
+ });
76
+ };
77
+
78
+ const DESIGN_ROOT = candidates.find((p) => holdsDesignSystem(p) || holdsOrgs(p));
79
+ if (!DESIGN_ROOT)
80
+ return {
81
+ problems: [{ level: "error", file: candidates[0], msg: `no design/ folder here (looked in: ${candidates.join(", ")})` }],
82
+ homes: [],
83
+ };
84
+
85
+ // ── tree layout ──
86
+ // New layout: design/marketplace/{atoms,components,styles} + one top-level folder per
87
+ // org (design/<org>/). Legacy layout: components/ + atoms/ at the root, orgs under design/orgs/.
88
+ // The DESIGN SYSTEM is the primary lint target; org folders get the SAME generic
89
+ // checks — nothing here may key on a specific org's name.
90
+ // WHERE THE DESIGN SYSTEM ACTUALLY IS. `design/marketplace/` exists in this monorepo and is
91
+ // deliberately absent from a developer's project (sync-starter.sh): the platform installs
92
+ // @unoverse-platform/marketplace, whose `definitions/` bundle carries components, atoms
93
+ // AND styles (bundle-defs.mjs). All three matter — the space-scale check reads
94
+ // styles/base/spacing, so a fallback finding only components would build a PARTIAL scale
95
+ // and reject valid steps, reporting false errors on correct work. Mirrors definitions.ts.
96
+ const DS = (() => {
97
+ const onDisk = join(DESIGN_ROOT, "marketplace");
98
+ if (existsSync(onDisk)) return onDisk;
99
+ const nodesHome = resolve(DESIGN_ROOT, "..", "nodes");
100
+ for (const c of [
101
+ join(nodesHome, "marketplace", "definitions"),
102
+ join(resolve(DESIGN_ROOT, "..", "plugins"), "node_modules", "@unoverse-platform", "marketplace", "definitions"),
103
+ ])
104
+ if (existsSync(c)) return c;
105
+ return DESIGN_ROOT; // none anywhere: shared refs will not resolve, and the findings will say so
106
+ })();
107
+ const legacyOrgsDir = join(DESIGN_ROOT, "orgs");
108
+ const orgDirs = (() => {
109
+ if (existsSync(legacyOrgsDir))
110
+ return readdirSync(legacyOrgsDir)
111
+ .filter((e) => !e.startsWith("."))
112
+ .map((e) => join(legacyOrgsDir, e))
113
+ .filter((d) => statSync(d).isDirectory());
114
+ // THE LEGACY LAYOUT, where the design system IS the root: `components/` and `atoms/`
115
+ // sit directly in design/, so listing children here would lint "components" as an org.
116
+ //
117
+ // NOT `DS === DESIGN_ROOT`, which was the same test until the design system stopped being
118
+ // guaranteed. DS falls back to DESIGN_ROOT when no design system is found ANYWHERE, which is the
119
+ // ordinary state of a developer's project — so that test read "this is the legacy
120
+ // layout" and returned no orgs, silently linting nothing at all.
121
+ if (existsSync(join(DESIGN_ROOT, "components")) || existsSync(join(DESIGN_ROOT, "atoms"))) return [];
122
+ return readdirSync(DESIGN_ROOT)
123
+ .filter((e) => !e.startsWith(".") && e !== "marketplace" && e !== "_schema")
124
+ .map((e) => join(DESIGN_ROOT, e))
125
+ .filter((d) => statSync(d).isDirectory());
126
+ })();
127
+
128
+ const problems = [];
129
+ const seen = new Set();
130
+ const report = (level, file, msg, line) => {
131
+ const rel = relative(process.cwd(), file);
132
+ const key = `${level}|${rel}|${line ?? ""}|${msg}`;
133
+ if (seen.has(key)) return;
134
+ seen.add(key);
135
+ problems.push({ level, file: rel, line, msg });
136
+ };
137
+
138
+ // ── helpers ──
139
+ function jsonFiles(dir) {
140
+ let out = [];
141
+ if (!existsSync(dir)) return out;
142
+ for (const f of readdirSync(dir)) {
143
+ if (f.startsWith(".")) continue;
144
+ const p = join(dir, f);
145
+ if (statSync(p).isDirectory()) out = out.concat(jsonFiles(p));
146
+ else if (isDefFile(f)) out.push(p);
147
+ }
148
+ return out;
149
+ }
150
+ const isFixture = (f) => /\.states\.(json|yaml)$/.test(f);
151
+ // A LIFECYCLE HOOK, not a UI definition: `onstart.yaml` beside a component declares the
152
+ // calls that fill it. It is checked by the lifecycle rules below and by the node call
153
+ // rules at run time, never by the primitive/token laws that govern a rendered tree.
154
+ // A LIFECYCLE HOOK file, not a UI definition: it declares the calls that fill a component
155
+ // and is named for its HANDLER. Recognised by a sibling manifest naming it.
156
+ const isHook = (f) => {
157
+ const dir = dirname(f);
158
+ const mf = defPath(dir, "manifest");
159
+ if (!mf || !existsSync(mf)) return false;
160
+ let life;
161
+ try { life = readDef(mf).lifecycle; } catch { return false; }
162
+ if (!Array.isArray(life)) return false;
163
+ const stem = basename(f).replace(/\.(json|ya?ml)$/, "").toLowerCase();
164
+ return life.some((e) => typeof e === "object" && e && typeof e.handler === "string" && e.handler.toLowerCase() === stem);
165
+ };
166
+ // THE PROPS EXTRACT (UNOVERSE_MCP_FORMS §Per-field purpose): a folder-root file the
167
+ // envelope pulls in with `props: { $include: <stem> }`. It is a props MAP, not a
168
+ // drawing — the node walk must not judge it, and the props rules read it composed.
169
+ const isPropsExtract = (f) => {
170
+ const dir = dirname(f);
171
+ const env = defPath(dir, basename(dir));
172
+ if (!env || env === f) return false;
173
+ let inc;
174
+ try { inc = readDef(env).props?.$include; } catch { return false; }
175
+ if (typeof inc !== "string") return false;
176
+ return basename(f).replace(/\.(json|ya?ml)$/, "").toLowerCase() === inc.toLowerCase();
177
+ };
178
+ const isManifest = (f) => /^manifest\.(json|yaml)$/.test(basename(f));
179
+ // WHICH HOME A FILE IS IN — asked of the path BELOW the design root, never the absolute
180
+ // one. Testing the absolute path for an `apps` segment was true of every file in this
181
+ // monorepo, because the design tree itself lives at `apps/unoverse/design`: every
182
+ // component manifest in the repo was linted as an APP manifest (two bogus warnings each,
183
+ // while the real component-manifest rules never ran once). A rule that cannot tell the
184
+ // homes apart is worse than no rule — it trains people to ignore its output.
185
+ const homeSegments = (f) => relative(DESIGN_ROOT, f).split(sep);
186
+ const isAppPath = (f) => homeSegments(f).includes("apps");
187
+ // THE TEMPLATE KIND (UNOVERSE_TEMPLATE_MODEL.md §9b). Its folders answer to different
188
+ // rules than an app's or a component's, so the rules that would misfire have to be able
189
+ // to ask. `isTemplatePart` is the template's OWN components/ folder — flat parts that
190
+ // carry no manifest and declare no state tree, the face being the filename (§9b rule 7).
191
+ const isTemplatePath = (f) => homeSegments(f).includes("templates");
192
+ const isTemplatePart = (f) => isTemplatePath(f) && homeSegments(f).includes("components");
193
+ // The DEFINITION ROOT folder for a file: a bare partial lives one level under it
194
+ // (layouts/ states/ components/ blocks/); every `$include` resolves against it.
195
+ const defRoot = (file) => {
196
+ const dir = dirname(file);
197
+ return PARTIAL_DIRS.has(basename(dir)) ? dirname(dir) : dir;
198
+ };
199
+
200
+ // The space scale — a bare numeric dimension value MUST be a real step, or the
201
+ // renderer passes it through as broken CSS and the element auto-sizes silently.
202
+ const spaceSteps = new Set(["0", "full", "auto"]);
203
+ /**
204
+ * step → the PAGE-WIDTH name that aliases it (`"160" → "reading"`), from semantic/layout.
205
+ *
206
+ * This is what makes the rule below safe. `maxWidth` is honestly two things: a PAGE
207
+ * container's cap and an ELEMENT's own cap (a card at `"90"`), so it cannot be required to
208
+ * carry a name outright without rejecting the second. But a step that HAS an alias has
209
+ * exactly one correct spelling, and that is the whole of the rule: not "name your widths",
210
+ * but "one value, one name". Steps with no alias are left alone, and adding an alias to
211
+ * `semantic/layout` extends the rule by itself.
212
+ */
213
+ const layoutAlias = new Map();
214
+ {
215
+ /**
216
+ * BOTH TIERS. The scale is numeric in `base/spacing` and NAMED in `semantic/spacing`
217
+ * ("md" → {space.4}), and the server reads both into one `space` bucket (theme.ts
218
+ * `readTokenDir` over base THEN semantic). Reading only `base` here meant the linter
219
+ * held a scale the renderer does not have: every t-shirt name was invisible to it, so
220
+ * the "Real steps:" list it printed omitted the names most definitions actually use,
221
+ * and an org that defined a numeric step in `semantic/` would have had correct work
222
+ * rejected. Same class of mistake as a partial app-size set — judge with the whole
223
+ * answer or not at all.
224
+ */
225
+ const spacingFiles = [DS, ...orgDirs].flatMap((d) => [
226
+ [defPath(join(d, "styles", "base"), "spacing"), "space"],
227
+ [defPath(join(d, "styles", "semantic"), "spacing"), "space"],
228
+ // PAGE WIDTHS by name (semantic/layout) are aliases onto this same scale, and `dim()`
229
+ // resolves them alongside it — so to the dimension check they ARE steps.
230
+ [defPath(join(d, "styles", "semantic"), "layout"), "layout"],
231
+ ]);
232
+ for (const [f, key] of spacingFiles) {
233
+ if (!f || !existsSync(f)) continue;
234
+ try {
235
+ const scale = readDef(f)[key] ?? {};
236
+ for (const [k, v] of Object.entries(scale)) {
237
+ if (k.startsWith("$")) continue;
238
+ spaceSteps.add(k);
239
+ // A layout name is an ALIAS onto a step (`reading` → `{space.160}`). Remember which
240
+ // step it covers, so the rule below can name the alias in its message and, more
241
+ // importantly, so it fires ONLY where an alias actually exists.
242
+ if (key === "layout") {
243
+ const step = /^\{space\.([^}]+)\}$/.exec(String(v?.$value ?? ""))?.[1];
244
+ if (step && !layoutAlias.has(step)) layoutAlias.set(step, k);
245
+ }
246
+ }
247
+ } catch { /* linted on its own */ }
248
+ }
249
+ }
250
+ /**
251
+ * KEYFRAME DECLS ARE KEBAB-CASE — one spelling, every renderer.
252
+ *
253
+ * A keyframe's declaration keys are real CSS property names, and the two serializers
254
+ * treated their casing differently: Studio's gallery kebab-cased them, the SDK emitted
255
+ * them verbatim. Today's keyframes only use single-word props (`transform`, `opacity`) so
256
+ * nobody noticed — but a `boxShadow` would have PLAYED in Studio and silently done nothing
257
+ * in the app. Both serializers now normalize, and this rule keeps the DATA canonical
258
+ * anyway: these files are the contract every future renderer (Flutter, RN, iOS) parses,
259
+ * and one value gets one spelling — the same law the space scale already enforces.
260
+ */
261
+ for (const home of [DS, ...orgDirs]) {
262
+ const f = defPath(join(home, "styles", "semantic"), "keyframes");
263
+ if (!f || !existsSync(f)) continue;
264
+ let kf;
265
+ try {
266
+ // Through `readText`, not straight off disk — Studio lints the file being TYPED
267
+ // via the overlay, and a rule that bypasses it judges stale content (see options.overlay).
268
+ kf = parseDef(readText(f), f).keyframes ?? {};
269
+ } catch {
270
+ continue; /* a malformed file lints on its own */
271
+ }
272
+ for (const [name, entry] of Object.entries(kf)) {
273
+ if (name.startsWith("$")) continue;
274
+ for (const [stop, decls] of Object.entries(entry?.$value ?? {})) {
275
+ if (!decls || typeof decls !== "object") continue;
276
+ for (const prop of Object.keys(decls))
277
+ if (/[A-Z_]/.test(prop))
278
+ report(
279
+ "error",
280
+ f,
281
+ `keyframes.${name}.${stop}: "${prop}" — keyframe declarations use kebab-case CSS property names ("${prop.replace(/_/g, "-").replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}"). The keyframe files are the one animation contract every renderer parses; one value, one spelling`,
282
+ );
283
+ }
284
+ }
285
+ }
286
+
287
+ const stepList = () => {
288
+ const all = [...spaceSteps];
289
+ const nums = all.filter((s) => /^\d/.test(s)).sort((a, b) => Number(a) - Number(b));
290
+ const named = all.filter((s) => !/^\d/.test(s)).sort();
291
+ return [...nums, ...named].join(", ");
292
+ };
293
+ /**
294
+ * A DIMENSION IS A SCALE STEP — numeric ("8") or named ("md"), both live in the same
295
+ * `space` bucket. The named half went unchecked, so `gap: mdd` resolved to nothing and
296
+ * CSS dropped the declaration: the gap silently became zero with a green lint run behind
297
+ * it. Same failure as an invented radius, one bucket over.
298
+ */
299
+ /**
300
+ * PAGE-LEVEL keys: a container's cap and the responsive thresholds. These are the places a
301
+ * width is a PAGE decision rather than an element's own size, so these are where the named
302
+ * scale belongs (`maxWidth: reading`, not `"160"`). See `layoutAlias` for why the rule is
303
+ * keyed on the alias existing rather than on the key alone.
304
+ */
305
+ const PAGE_WIDTH_KEYS = new Set(["maxWidth", "hideBelow", "hideAbove", "stackBelow"]);
306
+
307
+ const checkDimension = (file, where, key, v) => {
308
+ for (const raw of Array.isArray(v) ? v : [v]) {
309
+ // A NUMBER is held to the same law as a numeric string — raw JSON numbers
310
+ // (`"maxWidth": 560`) must not smuggle pixel values past the scale.
311
+ const val = typeof raw === "number" ? String(raw) : raw;
312
+ if (typeof val !== "string" || val.includes("{{")) continue;
313
+ if (spaceSteps.size <= 3) continue; // no scale readable: abstain rather than guess
314
+ // ONE VALUE, ONE SPELLING. A page-level width whose step has a name must use the name,
315
+ // or the scale drifts straight back to two vocabularies for the same number — which is
316
+ // the state the t-shirt aliases left it in, and the reason they were retired.
317
+ if (PAGE_WIDTH_KEYS.has(key) && layoutAlias.has(val))
318
+ report("error", file, `${where}.${key}: "${val}" has a name — use "${layoutAlias.get(val)}". A page-level width reads as what it IS; a bare step here puts two spellings on one value (docs.unoverse.ai/design/styles-and-tokens)`);
319
+ // A shorthand ("auto auto 0 0" on `inset`) is a list of dimensions; each word is one.
320
+ for (const word of val.trim().split(/\s+/)) {
321
+ if (spaceSteps.has(word) || DIMENSION_LITERALS.has(word)) continue;
322
+ // Anything starting with a digit that is NOT a bare step is a unit-bearing or
323
+ // percentage value; LAW 1 owns those, and `calc()`/`%` are legitimate escape hatches.
324
+ if (/^\d/.test(word) && !/^\d+(\.\d+)?$/.test(word)) continue;
325
+ if (/[()%]/.test(word)) continue; // calc(), min(), clamp(), 50%
326
+ report("error", file, `${where}.${key}: "${word}" is not a step on the space scale. Invalid values fall through as broken CSS (auto sizing). Real steps: ${stepList()} (docs.unoverse.ai/design/styles-and-tokens)`);
327
+ }
328
+ }
329
+ };
330
+
331
+ /**
332
+ * LAW 1's OTHER HALF — the name has to resolve.
333
+ *
334
+ * The raw-value rule (file.mjs) catches `#ff0000` and `12px`. It cannot catch `radius: lgg`,
335
+ * and nothing downstream catches it either: the SDK resolves `theme.radius[name] ?? name`,
336
+ * hands CSS the literal `lgg`, and CSS drops the declaration. The corner renders square,
337
+ * the lint run is green, and the only report is a designer noticing weeks later.
338
+ *
339
+ * `font` is the worst of them — it is applied ONLY on a hit (`if (s.font && theme.text[s.font])`),
340
+ * so an invented text style applies no size, weight or line-height whatsoever.
341
+ *
342
+ * The rule ABSTAINS rather than guesses: `tokensForFile` returns null wherever the token
343
+ * set cannot be fully read (a developer's project, which has no design system on disk),
344
+ * because judging against half a set rejects correct work — the trap `appSizesForFile`
345
+ * already documents at length.
346
+ */
347
+ const tokensForFile = makeTokensForFile({ DS, orgDirs });
348
+ const checkToken = (file, where, key, v) => {
349
+ const T = tokensForFile(file);
350
+ if (!T) return;
351
+ // A bound value is DATA — resolved from the record at render time, unknowable here.
352
+ const named = (x) => typeof x === "string" && !x.includes("{{") && x.trim() !== "";
353
+ const bad = (k, val, bucket, extra = "") =>
354
+ report(
355
+ "error",
356
+ file,
357
+ `${where}.${k}: "${val}" is not a ${bucket} token. Unknown names are handed to CSS verbatim and dropped — the style simply does not apply, with no error anywhere${extra}. Known: ${[...T[bucket]].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`,
358
+ );
359
+
360
+ // The straightforward one-bucket keys (background/color/shadow/radius*/font/lineHeight).
361
+ const bucket = TOKEN_KEYS[key];
362
+ if (bucket) {
363
+ if (!named(v)) return;
364
+ if (LITERAL_VALUES.has(v)) return;
365
+ // `lineHeight` legitimately takes a bare ratio (1.4) as well as a token.
366
+ if (key === "lineHeight" && /^\d*\.?\d+$/.test(v)) return;
367
+ // A bare `0` radius is the one dimensionless CSS value that needs no unit and has no
368
+ // token (a squared corner on a chat bubble). It resolves; nothing is dropped.
369
+ if (bucket === "radius" && v === "0") return;
370
+ if (!T[bucket].has(v))
371
+ bad(key, v, bucket, key === "font" ? ", and an unknown text style applies NO size, weight or line-height at all" : "");
372
+ return;
373
+ }
374
+
375
+ // `border` is a PAIR: an optional width token then a colour, e.g. "thick action.primary".
376
+ // The colour resolves as `color.border.<name>` first, then `color.<name>` (sdk/style.ts).
377
+ if (/^border(Top|Right|Bottom|Left)?$/.test(key)) {
378
+ if (!named(v) || LITERAL_VALUES.has(v)) return;
379
+ const parts = v.trim().split(/\s+/);
380
+ const [w, c] = parts.length > 1 ? parts : [null, parts[0]];
381
+ if (w !== null && !T.borderWidth.has(w))
382
+ report("error", file, `${where}.${key}: "${w}" is not a border-width token (the leading word of "<width> <colour>"). Known: ${[...T.borderWidth].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`);
383
+ if (!LITERAL_VALUES.has(c) && !T.color.has(`border.${c}`) && !T.color.has(c))
384
+ report("error", file, `${where}.${key}: "${c}" resolves to no colour token (tried border.${c}, then ${c}). The border renders with no colour and nothing reports it (docs.unoverse.ai/design/styles-and-tokens)`);
385
+ return;
386
+ }
387
+
388
+ // `weight` — an ordinary served token (`font.weight.*`) since the SDK stopped holding
389
+ // the scale as a literal map. A raw number is CSS's own form and passes through.
390
+ if (key === "weight") {
391
+ if (!named(v) || /^\d+$/.test(v)) return;
392
+ if (!T.weight.has(v)) bad("weight", v, "weight");
393
+ return;
394
+ }
395
+
396
+ // `radial` — both stops are colour tokens; `at` is a value or a binding.
397
+ if (key === "radial" && v && typeof v === "object") {
398
+ for (const k of ["fill", "track"])
399
+ if (named(v[k]) && !LITERAL_VALUES.has(v[k]) && !T.color.has(v[k])) bad(`radial.${k}`, v[k], "color");
400
+ return;
401
+ }
402
+
403
+ // `animation` is an OBJECT ({ name, duration?, easing?, iteration? }). The string form
404
+ // (`animation: kenBurns`) slipped past every rule and rendered `uno-undefined` — the SDK
405
+ // destructures `.name` from an object and a string has none, so the component named for
406
+ // its motion had none, silently. A shape the interpreter cannot read is an error here.
407
+ if (key === "animation" && typeof v === "string")
408
+ report("error", file, `${where}.animation: "${v}" is a string, but animation is an object — write animation: { name: ${v}, duration: …, easing: … }. The SDK reads .name from an object; a bare string animates NOTHING (renders "uno-undefined")`);
409
+
410
+ // `animation.name` names a SERVED keyframe; an unknown one animates nothing.
411
+ if (key === "animation" && v && typeof v === "object" && named(v.name) && !T.keyframes.has(v.name))
412
+ bad("animation.name", v.name, "keyframes");
413
+
414
+ // The Icon primitive's glyph (passed by walkNode, not a style key).
415
+ if (key === "icon" && named(v) && !T.icons.has(v)) bad("icon", v, "icons");
416
+ };
417
+
418
+ // atoms — case-insensitive by filename (the platform's lookup rule)
419
+ const atomsDirExists = existsSync(join(DS, "atoms"));
420
+ const atomNames = new Set(
421
+ (atomsDirExists ? readdirSync(join(DS, "atoms")) : [])
422
+ .filter(isDefFile)
423
+ .map((f) => defName(f).toLowerCase()),
424
+ );
425
+
426
+ // A Ref resolves an atom OR a marketplace component (shared flat chrome like
427
+ // ComposerBar). Component folders are named by their id — collect them so a
428
+ // Ref to a shared component isn't flagged as a missing atom.
429
+ const dsComponentsDir = join(DS, "components");
430
+ const dsComponentNames = new Set(
431
+ (existsSync(dsComponentsDir) ? readdirSync(dsComponentsDir, { withFileTypes: true }) : [])
432
+ .filter((e) => !e.name.startsWith("."))
433
+ .map((e) => (e.isDirectory() ? e.name : defName(e.name)).toLowerCase()),
434
+ );
435
+ // The ORG tier's component names, per org (docs.unoverse.ai/design/components):
436
+ // a Ref may also resolve an org component — bare from inside that org's own tree (the
437
+ // resolver's context rule), or org-qualified as `<org>/<name>` when two orgs share the
438
+ // name. Org-privacy holds: a file only ever reaches its OWN org's components, and a
439
+ // design-system file reaches none.
440
+ const orgComponentNames = new Map(); // org dir basename -> Set(lower names)
441
+ for (const orgDir of orgDirs) {
442
+ const home = join(orgDir, "components");
443
+ const set = new Set();
444
+ if (existsSync(home))
445
+ for (const e of readdirSync(home)) {
446
+ if (e.startsWith(".")) continue;
447
+ const name = statSync(join(home, e)).isDirectory() ? e : isDefFile(e) ? defName(e) : null;
448
+ if (name) set.add(name.toLowerCase());
449
+ }
450
+ orgComponentNames.set(basename(orgDir), set);
451
+ }
452
+ // TEMPLATE-LOCAL PARTS are addressed `<template>/<part>` (§9b rule 7) — the same
453
+ // qualified shape as `<org>/<name>`, resolved against the template homes. The runtime
454
+ // resolver has always done this (componentDirs' fallback); the lint did not, so a part
455
+ // composing a sibling part read as an unresolvable Ref while it rendered perfectly.
456
+ const templatePartNames = new Map(); // template folder name -> Set(lower part names)
457
+ // BLOCKS (§6b-0, 2026-09-02): a flat def file directly under a templates home is a small
458
+ // reusable template — Ref'able bare from its own org (the loader's third fallback).
459
+ const orgTemplateNames = new Map(); // org (or DS basename) -> Set(lower block names)
460
+ for (const root of [DS, ...orgDirs]) {
461
+ const home = join(root, "templates");
462
+ if (!existsSync(home)) continue;
463
+ const blocks = orgTemplateNames.get(basename(root)) ?? new Set();
464
+ for (const t of readdirSync(home)) {
465
+ if (isDefFile(t)) {
466
+ blocks.add(defName(t).toLowerCase());
467
+ continue;
468
+ }
469
+ const parts = join(home, t, "components");
470
+ if (!existsSync(parts)) continue;
471
+ const set = templatePartNames.get(t.toLowerCase()) ?? new Set();
472
+ for (const e of readdirSync(parts)) {
473
+ if (isDefFile(e)) set.add(defName(e).toLowerCase());
474
+ // FOLDER-FORM PARTS (moment/, chapter/): the part is <name>/<name>.yaml. The flat
475
+ // scan above missed them, so a part in earned structure read as unresolvable while
476
+ // it rendered perfectly.
477
+ else if (existsSync(join(parts, e, e + ".yaml"))) set.add(e.toLowerCase());
478
+ }
479
+ templatePartNames.set(t.toLowerCase(), set);
480
+ }
481
+ orgTemplateNames.set(basename(root), blocks);
482
+ }
483
+ const orgOfFile = (file) => {
484
+ const home = orgDirs.find((d) => file.startsWith(d + sep));
485
+ return home ? basename(home) : null;
486
+ };
487
+ // A valid Ref target if there's ANY resolvable home the FILE may reach: atoms, DS
488
+ // components, or (from inside an org tree) that org's own components — bare or
489
+ // `<org>/<name>`-qualified.
490
+ const refResolves = (ref, file) => {
491
+ const lower = ref.toLowerCase();
492
+ const fileOrg = file ? orgOfFile(file) : null;
493
+ const slash = lower.indexOf("/");
494
+ if (slash > 0) {
495
+ const qualifier = lower.slice(0, slash);
496
+ const name = lower.slice(slash + 1);
497
+ // A template's own part, composing another part of the same template.
498
+ if (templatePartNames.get(qualifier)?.has(name)) return true;
499
+ if (qualifier !== fileOrg) return false; // another org's, or a DS file naming any org
500
+ return orgComponentNames.get(qualifier)?.has(name) ?? false;
501
+ }
502
+ if (atomNames.has(lower) || dsComponentNames.has(lower)) return true;
503
+ if (fileOrg && orgComponentNames.get(fileOrg)?.has(lower)) return true;
504
+ // A BLOCK — a flat template, own org first, then the design system's (loader order).
505
+ if (fileOrg && orgTemplateNames.get(fileOrg)?.has(lower)) return true;
506
+ return orgTemplateNames.get(basename(DS))?.has(lower) ?? false;
507
+ };
508
+
509
+ /**
510
+ * THE KEY IS ONE STRING, CASE AND ALL.
511
+ *
512
+ * Ref lookup above is case-insensitive on purpose, and the marketplace is not: an item is
513
+ * fetched as `items/<kind>/<key>.json` over HTTP, off a case-sensitive host. So a name that
514
+ * disagrees with itself resolves forever in `design/` and 404s the moment anyone installs it.
515
+ *
516
+ * That is not hypothetical. 2026-08-06: twelve atoms were unreachable from every universe
517
+ * because git held `Avatar.json` while the build wrote `avatar.json`. macOS is
518
+ * case-insensitive, so every local check passed, git never recorded the rename, and Linux
519
+ * served what git held. The error surfaced as `could not fetch atom/avatar (HTTP 404)` in
520
+ * the Installed view, months after the cause.
521
+ *
522
+ * Nothing in `design/` could have caught it, because `design/` was correct. What was missing was a
523
+ * rule that the key agrees with itself EXACTLY, which is what this checks:
524
+ *
525
+ * filename === `name:` === every Ref that points at it
526
+ *
527
+ * Compared as strings, never through the filesystem. `existsSync("avatar.json")` matches
528
+ * `Avatar.json` on a Mac, which is precisely how this stayed invisible.
529
+ */
530
+ // COMPONENTS FIRST so ATOMS OVERWRITE THEM, matching `refResolves` above, which tries
531
+ // atoms before components. A name can exist as both (`table` is an atom AND a shared
532
+ // component); resolution picks the atom, so the canonical spelling must be the atom's.
533
+ // Built the other way round, this told an author to write `Table` for a Ref that resolves
534
+ // to the atom `table` — a correction that would have broken what it touched.
535
+ const canonicalKeys = new Map(); // lowercased key -> the exact-case key
536
+ for (const [dir, isDir] of [[join(DS, "components"), true], [join(DS, "atoms"), false]]) {
537
+ if (!existsSync(dir)) continue;
538
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
539
+ if (e.name.startsWith(".")) continue;
540
+ if (isDir ? !e.isDirectory() : !isDefFile(e.name)) continue;
541
+ const key = isDir ? e.name : defName(e.name);
542
+ canonicalKeys.set(key.toLowerCase(), key);
543
+ }
544
+ }
545
+
546
+ /** The exact-case key a Ref should use, or null when it resolves to nothing. */
547
+ const canonicalRef = (ref) => canonicalKeys.get(ref.toLowerCase()) ?? null;
548
+
549
+ /**
550
+ * THE PROPS AN ATOM ACTUALLY DECLARES.
551
+ *
552
+ * A `Ref` parameterises an atom two ways, and BOTH are looked up by name: `props` remaps
553
+ * the atom's own field names onto the host's, `with` supplies literals for them. A key
554
+ * the atom does not declare matches nothing, and the expander ignores it in silence.
555
+ *
556
+ * That silence has cost real screens. `form-toggle` declares `on` and `description`; a
557
+ * form wrote `props: { value: … }` and `with: { help: … }`, so the switch bound to nothing
558
+ * and the sub-line simply never appeared. Everything rendered, nothing was wrong to look
559
+ * at, and the control did not move. Nothing in design/ could catch it because every node was
560
+ * structurally valid.
561
+ *
562
+ * So the keys are checked against the atom's own `props` block. Read lazily and cached:
563
+ * most definitions reference a handful of atoms, and a lint run should not parse the
564
+ * whole design system to check one file.
565
+ */
566
+ const atomPropsCache = new Map(); // canonical ref -> Set of declared prop names, or null
567
+ function declaredProps(ref) {
568
+ const key = canonicalRef(ref);
569
+ if (!key) return null;
570
+ if (atomPropsCache.has(key)) return atomPropsCache.get(key);
571
+ let props = null;
572
+ // Atoms are a file; a shared DS component is a folder holding its own definition. Only
573
+ // the atom form declares a prop contract this rule can check.
574
+ const file = defPath(join(DS, "atoms"), key);
575
+ if (file) {
576
+ try {
577
+ const def = readDef(file);
578
+ if (def && def.props && typeof def.props === "object") props = new Set(Object.keys(def.props));
579
+ } catch {
580
+ props = null; // unreadable: never invent a contract the author would have to satisfy
581
+ }
582
+ }
583
+ atomPropsCache.set(key, props);
584
+ return props;
585
+ }
586
+
587
+ // The filename is the key. A `name:` that disagrees with it is the drift above, waiting.
588
+ //
589
+ // EVERY HOME, not just the design system. This checked `marketplace/atoms` and
590
+ // `marketplace/components` only, so an org's own components and templates were invisible to
591
+ // it and their drift had to be found by hand: `course-card/` holding `name: CourseCard`, and
592
+ // every template folder holding a display sentence (`name: BPP Assistant`) where the key
593
+ // belongs. A rule that covers the shared tier and not the private one teaches that the
594
+ // private one is exempt.
595
+ const keyHomes = [
596
+ [join(DS, "atoms"), false],
597
+ [join(DS, "components"), true],
598
+ ];
599
+ for (const orgDir of orgDirs) {
600
+ keyHomes.push([join(orgDir, "components"), true], [join(orgDir, "apps"), true]);
601
+ }
602
+ for (const [dir, isDir] of keyHomes) {
603
+ if (!existsSync(dir)) continue;
604
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
605
+ if (e.name.startsWith(".")) continue;
606
+ if (isDir ? !e.isDirectory() : !isDefFile(e.name)) continue;
607
+ const key = isDir ? e.name : defName(e.name);
608
+ // A TEMPLATE KEEPS ITS DEFINITION IN `manifest.yaml`, a component in `<name>.yaml`.
609
+ // Looking only for the latter meant every template folder was skipped in silence, so
610
+ // the rule reported them clean while each held a display sentence where its key belongs.
611
+ // A guard that cannot find a file must not read that as nothing to check.
612
+ const file = isDir
613
+ ? [`${e.name}.yaml`, `${e.name}.json`, "manifest.yaml", "manifest.json"]
614
+ .map((n) => join(dir, e.name, n))
615
+ .find(existsSync)
616
+ : join(dir, e.name);
617
+ if (!file || !existsSync(file)) continue;
618
+ const def = readDef(file);
619
+ if (!def || typeof def.name !== "string") continue;
620
+ if (def.name !== key)
621
+ report(
622
+ "warn",
623
+ file,
624
+ `name: "${def.name}" disagrees with the filename "${key}". The key is fetched as items/<kind>/${key}.json over HTTP, so the two must match exactly, capitals included`,
625
+ );
626
+ }
627
+ }
628
+
629
+ // definition homes: the marketplace's components/atoms/templates + each org's apps,
630
+ // components and templates (same law everywhere — an org component is a component,
631
+ // just org-private).
632
+ //
633
+ // TEMPLATES ARE A KIND, AND A KIND IS LINTED (2026-08-30). The Template Model's
634
+ // `templates/` folders were never added here, so the entire kind was invisible: an
635
+ // invented token or an off-scale space step inside `<org>/templates/**` reported clean,
636
+ // while the same file under `components/` threw two errors. A home that is not
637
+ // enumerated is a home where every rule is off, silently — and the parts a template
638
+ // carries are ordinary components, so they were never exempt from anything.
639
+ const homes = [{ dir: join(DS, "components") }, { dir: join(DS, "atoms") }, { dir: join(DS, "templates") }];
640
+ for (const orgDir of orgDirs) {
641
+ for (const sub of ["apps", "components", "templates"]) {
642
+ const d = join(orgDir, sub);
643
+ if (existsSync(d) && statSync(d).isDirectory()) homes.push({ dir: d });
644
+ }
645
+ }
646
+
647
+ // ── component-name uniqueness (per tier, no marketplace shadowing) ──
648
+ // Names are unique WITHIN a home: two orgs may ship the same name (each addressed
649
+ // `unoverse://components/<org>/<name>` — docs.unoverse.ai/design/components),
650
+ // but an org may never SHADOW a marketplace name, so a bare ref stays unambiguous:
651
+ // bare = the marketplace tier, or the single org that carries the name.
652
+ {
653
+ const marketplaceNames = new Map(); // lower-name -> path in the marketplace tier
654
+ const dsHome = join(DS, "components");
655
+ const namesIn = (home) => {
656
+ const out = new Map(); // lower-name -> path
657
+ if (!existsSync(home)) return out;
658
+ for (const e of readdirSync(home)) {
659
+ if (e.startsWith(".")) continue;
660
+ const p = join(home, e);
661
+ const name = (statSync(p).isDirectory() ? e : isDefFile(e) ? defName(e) : null)?.toLowerCase();
662
+ if (!name) continue;
663
+ if (out.has(name))
664
+ report("error", p, `component name "${name}" already exists at ${relative(DESIGN_ROOT, out.get(name))}. Names are UNIQUE within a home; rename one`);
665
+ else out.set(name, p);
666
+ }
667
+ return out;
668
+ };
669
+ for (const [name, p] of namesIn(dsHome)) marketplaceNames.set(name, p);
670
+ for (const orgDir of orgDirs) {
671
+ for (const [name, p] of namesIn(join(orgDir, "components"))) {
672
+ const shadowed = marketplaceNames.get(name);
673
+ if (shadowed)
674
+ report("error", p, `component name "${name}" shadows the marketplace component at ${relative(DESIGN_ROOT, shadowed)}. An org may never shadow a marketplace name (bare refs must stay unambiguous); rename the org component`);
675
+ }
676
+ }
677
+ }
678
+
679
+ // ── one DEFAULT app per org (the /mcp/<org> front door) ──
680
+ // Exactly one app is an org's home; a host opens the app whose manifest sets `default: true`
681
+ // as the conversation's entry point. Two defaults = ambiguous front door → error.
682
+ for (const orgDir of orgDirs) {
683
+ const tdir = join(orgDir, "apps");
684
+ if (!(existsSync(tdir) && statSync(tdir).isDirectory())) continue;
685
+ const defaults = [];
686
+ for (const e of readdirSync(tdir)) {
687
+ const mf = defPath(join(tdir, e), "manifest");
688
+ if (!existsSync(mf)) continue;
689
+ try {
690
+ if (readDef(mf).default === true) defaults.push(mf);
691
+ } catch {
692
+ /* malformed manifest is reported elsewhere */
693
+ }
694
+ }
695
+ if (defaults.length > 1)
696
+ for (const mf of defaults)
697
+ report(
698
+ "error",
699
+ mf,
700
+ `org "${org}" has ${defaults.length} apps with "default": true. An org has exactly ONE default app (its /mcp/${org} front door). Keep it on one manifest, remove it from the others (docs.unoverse.ai/design/apps §4b)`,
701
+ );
702
+ }
703
+
704
+ /**
705
+ * ── the LATCH, on a state (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) ──
706
+ *
707
+ * Declaring it makes whatever wears that face ADDRESSABLE: the pill goes up and the guest's
708
+ * next sentences travel to that instance instead of opening a new answer. Every finding here
709
+ * is something that produces a pill which looks fine and behaves wrongly, which is why they
710
+ * are errors rather than warnings.
711
+ *
712
+ * Read from the ENVELOPE first and the manifest second, the same order the loader reads the
713
+ * states tree in — a rule that judged only one home would pass whatever was authored in the
714
+ * other.
715
+ */
716
+ const LATCH_KEYS = new Set(["title", "icon"]);
717
+ for (const orgDir of orgDirs) {
718
+ const adir = join(orgDir, "apps");
719
+ if (!(existsSync(adir) && statSync(adir).isDirectory())) continue;
720
+ for (const e of readdirSync(adir)) {
721
+ const folder = join(adir, e);
722
+ let src, states;
723
+ try {
724
+ if (!statSync(folder).isDirectory()) continue;
725
+ // The envelope (`<name>.yaml`) is the app tree's home; the manifest is the legacy one.
726
+ const envelope = defPath(folder, e);
727
+ const manifest = defPath(folder, "manifest");
728
+ for (const p of [envelope, manifest]) {
729
+ if (!p || !existsSync(p)) continue;
730
+ const s = readDef(p).states;
731
+ if (s && typeof s === "object" && !Array.isArray(s)) {
732
+ src = p;
733
+ states = s;
734
+ break;
735
+ }
736
+ }
737
+ } catch {
738
+ continue; // a malformed envelope is reported elsewhere
739
+ }
740
+ if (!states) continue;
741
+ for (const [sname, sentry] of Object.entries(states)) {
742
+ const latch = sentry && typeof sentry === "object" && !Array.isArray(sentry) ? sentry.latch : undefined;
743
+ if (latch === undefined) continue;
744
+ if (typeof latch !== "object" || latch === null || Array.isArray(latch)) {
745
+ report("error", src, `state "${sname}": "latch" must be a block ({ title, icon }), not ${Array.isArray(latch) ? "a list" : typeof latch}. Presence is what makes a state addressable, so a malformed one is an intent nothing can honour (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`);
746
+ continue;
747
+ }
748
+ // The pill is the ONLY sign the guest has that their words are landing on this page
749
+ // rather than the conversation. Unlabelled, it says nothing — and the compiler drops
750
+ // a titleless block, so the state would silently stop being addressable.
751
+ if (typeof latch.title !== "string" || !latch.title.trim())
752
+ report("error", src, `state "${sname}": "latch" needs a "title" — it is what the pill reads, and the pill is the only thing telling the guest what they are addressing. Name the thing, not the verb ("Your Perfect Day", not "Edit the day")`);
753
+ const extra = Object.keys(latch).filter((k) => !LATCH_KEYS.has(k));
754
+ if (extra.length)
755
+ report("error", src, `state "${sname}": "latch" has no key(s) ${extra.join(", ")}. The block is exactly ${[...LATCH_KEYS].join(", ")} — an invented key is silently ignored, so whatever it was meant to do never happens. The pill's colours belong to the design system's composer-bar, never to a state`);
756
+ // A STATE NOTHING CAN ENTER CANNOT BE ADDRESSED. The base state is the app at rest —
757
+ // the conversation itself — and `stateOrder` excludes it, so `resolveLatch` can never
758
+ // rank a base-state instance and the pill would never appear.
759
+ if (sname === Object.keys(states)[0])
760
+ report("error", src, `state "${sname}": it is the BASE state — the app at rest, which the priority ladder excludes by construction (stateOrder is every state BUT the base). A latch here can never resolve, so no pill would ever draw. Declare it on a state something ARRIVES into`);
761
+ }
762
+ }
763
+ }
764
+
765
+ // ── lifecycle declarations (the ONE sanctioned code carve-out) ──
766
+ // A component may run server-side code at a platform fire point, and the safety of that
767
+ // rests on the manifest and the thing that runs agreeing. Both halves are checked here so
768
+ // a bad declaration is caught in the terminal and at publish, not by silence at run time.
769
+ // Mirrors apps/unoverse/server/tests/design/lifecycle-declaration.test.ts (UNOVERSE_AUTHORING.md §3c).
770
+ const KNOWN_LIFECYCLES = new Set(["onStart", "onEnterView"]);
771
+ const PHASES_WITH_LAYOUTS = new Set(["onEnterView"]); // phases that fire per VIEW
772
+ const PLATFORM_HANDLERS = new Set(["getDetail"]); // named handlers needing no file
773
+
774
+ // Every credential DEFINITION this universe can offer a form for. They ship with node
775
+ // packages (nodes/<pkg>/credentials/<name>.yaml); a component NAMES one, it never defines
776
+ // one. Declared before the loop that reads it: a `const` referenced above its own
777
+ // declaration throws, and the throw is swallowed by the enclosing try, which would
778
+ // silently disable every rule after it.
779
+ const credentialDefs = new Set();
780
+ {
781
+ const nodesHome = resolve(DESIGN_ROOT, "..", "nodes");
782
+ try {
783
+ for (const pkg of readdirSync(nodesHome)) {
784
+ try {
785
+ for (const f of readdirSync(join(nodesHome, pkg, "credentials"))) if (isDefFile(f)) credentialDefs.add(defName(f));
786
+ } catch {
787
+ /* this package ships none */
788
+ }
789
+ }
790
+ } catch {
791
+ /* no nodes tree beside design/: the rule cannot judge, so it stays quiet */
792
+ }
793
+ }
794
+
795
+ for (const orgDir of [DS, ...orgDirs]) {
796
+ const cdir = join(orgDir, "components");
797
+ if (!(existsSync(cdir) && statSync(cdir).isDirectory())) continue;
798
+ for (const entry of readdirSync(cdir)) {
799
+ const folder = join(cdir, entry);
800
+ let mf;
801
+ try {
802
+ if (!statSync(folder).isDirectory()) continue;
803
+ mf = defPath(folder, "manifest");
804
+ } catch {
805
+ continue;
806
+ }
807
+ if (!mf || !existsSync(mf)) continue;
808
+ let raw;
809
+ try {
810
+ raw = readDef(mf).lifecycle;
811
+ } catch {
812
+ continue; // malformed manifest is reported elsewhere
813
+ }
814
+ const entries = Array.isArray(raw)
815
+ ? raw
816
+ .map((r) => (typeof r === "string" ? { phase: r } : r && typeof r === "object" && typeof r.phase === "string" ? r : null))
817
+ .filter(Boolean)
818
+ : [];
819
+ const declared = new Set(entries.map((e) => e.phase.toLowerCase()));
820
+
821
+ for (const e of entries) {
822
+ if (!KNOWN_LIFECYCLES.has(e.phase))
823
+ report("error", mf, `lifecycle "${e.phase}" is not a phase the platform fires, so it would never run. Known: ${[...KNOWN_LIFECYCLES].join(", ")} (docs.unoverse.ai/unoverse/UNOVERSE_AUTHORING §3c)`);
824
+ if (e.layouts !== undefined && !PHASES_WITH_LAYOUTS.has(e.phase))
825
+ report("error", mf, `lifecycle "${e.phase}" declares layouts, but only ${[...PHASES_WITH_LAYOUTS].join(", ")} fires per view — the scope would be ignored`);
826
+ // WHAT RUNS is named by `handler`; the phase only says WHEN. A custom hook is a
827
+ // YAML file named for the handler (what it DOES), never for the phase.
828
+ const custom = e.handler && !PLATFORM_HANDLERS.has(e.handler) ? `${e.handler.toLowerCase()}.yaml` : null;
829
+ const hookYaml = custom ? existsSync(join(folder, custom)) : false;
830
+ if (custom && !hookYaml)
831
+ report("error", mf, `lifecycle "${e.phase}" names handler "${e.handler}", so ${custom} must sit beside this manifest (a custom hook is named for what it does). Or name a platform handler: ${[...PLATFORM_HANDLERS].join(", ")}`);
832
+ if (!e.handler && !existsSync(join(folder, `${e.phase.toLowerCase()}.js`)))
833
+ report("error", mf, `lifecycle "${e.phase}" declares no "handler", so nothing would run. Name a platform handler (${[...PLATFORM_HANDLERS].join(", ")}) or your own, with the matching <handler>.yaml beside this manifest`);
834
+ // Declared calls reach the network, so the hosts they may reach are not optional.
835
+ if (hookYaml && !Array.isArray(readDef(mf).allowedHosts))
836
+ report("error", mf, `${custom} makes requests, so this manifest must declare "allowedHosts". Deny by default: no list, no network`);
837
+
838
+ // A credential is a NAME (owner ruling 2026-08-26): it references a value that
839
+ // exists in the universe or does not, and shipping NEVER blocks on it — the gap is
840
+ // a WARNING here and a loud runtime warning when the hook runs without it
841
+ // (resolveCredentials says so on every keyless run). A missing DEFINITION only
842
+ // means no package offers a pre-built entry form; the value can still be provided.
843
+ // This was an org-blocking error until it stopped a yasisland deploy over a form
844
+ // nobody needed that day.
845
+ if (credentialDefs.size)
846
+ for (const cname of Array.isArray(readDef(mf).credentials) ? readDef(mf).credentials : [])
847
+ if (!credentialDefs.has(String(cname)))
848
+ report(
849
+ "warn",
850
+ mf,
851
+ `credential "${cname}" has no definition in any installed node package, so no pre-built form exists to enter it — the hook will run without it until a value is provided. Installed definitions: ${[...credentialDefs].sort().join(", ")}`,
852
+ );
853
+
854
+ }
855
+
856
+ // A handler FILE nothing opted into is un-opted code: it never runs, and a reader
857
+ // cannot tell that from looking. Name it in the manifest, or delete it.
858
+ for (const f of readdirSync(folder))
859
+ if (f.endsWith(".js") && !declared.has(f.slice(0, -3)))
860
+ report("error", join(folder, f), `${f} is a lifecycle handler no manifest opted into. Add "${f.slice(0, -3)}" to the manifest's lifecycle array, or delete the file — un-opted code never runs`);
861
+
862
+ /**
863
+ * LATCH, RETIRED FROM THE MANIFEST (2026-08-31, MCP guide §The Component Latch).
864
+ *
865
+ * It moved to the STATE, in the app's own tree, beside that state's `layout:` and
866
+ * `preview:`. A component said one thing about itself for the whole of its life, but a
867
+ * page is addressable when it IS the page and not when it is a summary in the flow, and
868
+ * only the state knows which of those is on screen.
869
+ *
870
+ * An ERROR rather than a silent ignore: nothing reads this key any more, so a manifest
871
+ * still carrying it declares an intent that never happens, and the author has no way to
872
+ * tell that from a latch that simply does not work.
873
+ */
874
+ if (readDef(mf).latch !== undefined)
875
+ report(
876
+ "error",
877
+ mf,
878
+ `"latch" is no longer a manifest key — it moved to the STATE (2026-08-31). Declare it on the app state this component is addressable IN, beside that state's layout: \`states: { <state>: { layout: ..., latch: { title: "..." } } }\`. Nothing reads a manifest latch, so this one produces no pill (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`,
879
+ );
880
+ }
881
+ }
882
+
883
+ // ── condition (visibleWhen / style.when entry) ──
884
+ function checkCondition(vw, file, where) {
885
+ if (typeof vw === "string") return; // bare truthy field
886
+ if (vw && typeof vw === "object" && !Array.isArray(vw)) {
887
+ if (typeof vw.field !== "string")
888
+ report("error", file, `${where}: condition needs a "field" (docs.unoverse.ai/design/state)`);
889
+ const extra = Object.keys(vw).filter((k) => !CONDITION_KEYS.has(k));
890
+ if (extra.length)
891
+ report("error", file, `${where}: illegal condition key(s) ${extra.join(", ")}. Only eq/ne/in/truthy exist; no and/or/arithmetic (derive in the node) (docs.unoverse.ai/design/components)`);
892
+ return;
893
+ }
894
+ report("error", file, `${where}: visibleWhen must be a field name or { field, eq|ne|in } (docs.unoverse.ai/design/state)`);
895
+ }
896
+
897
+ // ── per-node structural walk ──
898
+ // `widthCap` = the tightest numeric maxWidth on this node's ancestor chain (space
899
+ // steps are monotonic, so step names compare numerically).
900
+ // The STANDARD APP SIZES the `appWidth` names may reference. An org's own
901
+ // styles/semantic/app-sizes wins; absent, the org INHERITS the marketplace set
902
+ // (new-org copies themes only — base+semantic are inherited). null = the file is
903
+ // under no sized tree at all. The old version matched only the LEGACY orgs/ path,
904
+ // so on the flat layout it returned null and the whole appWidth check silently
905
+ // skipped — which is how an unresolvable token shipped and cost a day (§9.10).
906
+ const appSizesCache = new Map();
907
+ function readAppSizes(dir) {
908
+ const p = defPath(join(dir, "styles", "semantic"), "app-sizes");
909
+ if (!p || !existsSync(p)) return null;
910
+ try {
911
+ const sizes = {};
912
+ for (const [k, v] of Object.entries(readDef(p).appSize ?? {})) sizes[k] = typeof v === "string" ? v : v?.$value;
913
+ return sizes;
914
+ } catch {
915
+ return null; // the file lints separately
916
+ }
917
+ }
918
+ function appSizesForFile(file) {
919
+ const home = orgDirs.find((d) => file.startsWith(d + sep)) ?? (file.startsWith(DS + sep) ? DS : null);
920
+ if (!home) return null;
921
+ if (!appSizesCache.has(home)) {
922
+ // INHERITANCE, not replacement: an org's own set OVERRIDES the marketplace's
923
+ // name-by-name (bpp redefines `chat` and adds `flex`; `rail`/`panel` stay inherited).
924
+ const inherited = readAppSizes(DS);
925
+ /**
926
+ * A PARTIAL SET IS WORSE THAN NO SET, and this is the same trap the design-system
927
+ * lookup above already names: judge against half the answer and correct work is
928
+ * rejected.
929
+ *
930
+ * A developer's project has NO design system on disk — it is installed, not authored
931
+ * — so the inherited half is unreadable and only the org's own names are left. Every
932
+ * inherited name it uses then "names no app size", which is a lint error on work that
933
+ * is right, and publishing lints first, so it blocked the publish outright.
934
+ *
935
+ * Unreadable is not empty. `null` skips the name check (walk.mjs guards on it), which
936
+ * is the honest answer: this cannot be verified here, so it is not asserted. In the
937
+ * monorepo the design system IS present and the check runs exactly as before.
938
+ */
939
+ appSizesCache.set(home, inherited === null ? null : { ...inherited, ...(readAppSizes(home) ?? {}) });
940
+ }
941
+ return appSizesCache.get(home);
942
+ }
943
+
944
+ // The universal component names (design/marketplace/components/*, case-insensitive) — for
945
+ // validating an app manifest's `preview` map. null = the file is not under a design tree.
946
+ const componentNamesCache = new Map();
947
+ // Components an ORG's template may reference: the marketplace tier + that org's OWN
948
+ // components — never another org's (org-privacy). Cached per org.
949
+ function componentNamesForFile(file) {
950
+ // THE PROJECT A FILE BELONGS TO, from the path below the design root. This read
951
+ // `${sep}orgs${sep}` and returned null for anything else, so on the flat layout — which
952
+ // is every project now (`design/<org>/…`, "flat wins") — it answered "no list" and
953
+ // every rule that asks it, preview names included, silently stopped checking.
954
+ const segs = relative(DESIGN_ROOT, file).split(sep);
955
+ if (segs.length < 2 || segs[0] === "..") return null;
956
+ const projectSegs = segs[0] === "orgs" ? segs.slice(0, 2) : segs.slice(0, 1);
957
+ const org = projectSegs.join("/");
958
+ const cached = componentNamesCache.get(org);
959
+ if (cached !== undefined) return cached;
960
+ const dirs = [
961
+ join(DS, "components"),
962
+ join(DESIGN_ROOT, ...projectSegs, "components"),
963
+ // TEMPLATES ARE PEERS (§9c): a state that holds one seeds it by name like any
964
+ // component, so the name set a preview is checked against includes them.
965
+ join(DS, "templates"),
966
+ join(DESIGN_ROOT, ...projectSegs, "templates"),
967
+ ];
968
+ let names = null;
969
+ for (const dir of dirs) {
970
+ if (!existsSync(dir)) continue;
971
+ names ??= new Set();
972
+ for (const e of readdirSync(dir)) names.add(defName(e).toLowerCase());
973
+ }
974
+ componentNamesCache.set(org, names);
975
+ return names;
976
+ }
977
+
978
+
979
+ // The run context: everything the extracted rule modules close over. Built here so it
980
+ // cannot outlive the run, and passed once rather than threaded as ten parameters.
981
+ const ctx = {
982
+ DESIGN_ROOT, DS, orgDirs, report, spaceSteps, stepList, checkDimension, checkToken, checkCondition,
983
+ appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists,
984
+ isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePath, isTemplatePart, defRoot, readText,
985
+ };
986
+ const walkNode = makeWalkNode(ctx);
987
+ const lintFile = makeLintFile({ ...ctx, walkNode });
988
+
989
+ for (const home of homes) for (const f of jsonFiles(home.dir)) lintFile(f);
990
+ return { problems, homes, designRoot: DESIGN_ROOT, designSystem: DS };
991
+ }
992
+
993
+ /** True when anything would fail a build. Warnings and hints inform, errors stop. */
994
+ export function hasErrors(result) {
995
+ return result.problems.some((p) => p.level === "error");
996
+ }