unoverse 0.1.183 → 0.1.184
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/operator/ansible/playbooks/deploy-images.yml +24 -11
- package/operator/ansible/playbooks/restore.yml +8 -4
- package/operator/ansible/playbooks/test-connectivity.yml +7 -4
- package/operator/docker-compose.yml +40 -3
- package/operator/lib/check.sh +26 -0
- package/operator/lib/db-verify.sh +3 -2
- package/operator/lib/deploy.sh +7 -3
- package/operator/operator.sh +6 -0
- package/package.json +1 -1
- package/vendor/base/items/collect.js +10 -10
- package/vendor/base/lint/design/file.mjs +254 -88
- package/vendor/base/lint/design/index.mjs +77 -13
- package/vendor/base/lint/design/vocabulary.mjs +36 -2
- package/vendor/base/lint/design/walk.mjs +85 -4
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
# DEPLOY IMAGES — the images half of the platform deploy.
|
|
3
3
|
#
|
|
4
4
|
# Pull the latest DOCR images and restart. That's the whole deployment:
|
|
5
|
-
# - platform code
|
|
6
|
-
# -
|
|
7
|
-
# row is "the newest version anyone chose"; boot reinstalls anything missing
|
|
8
|
-
# or older — see runtime/plugins/startup.ts)
|
|
5
|
+
# - platform code = the images
|
|
6
|
+
# - node manifests are declarative YAML, loaded at boot
|
|
9
7
|
# - component nodes synthesize from design/ definitions at boot (no build, ever)
|
|
8
|
+
# - marketplace CONTENT is database rows and does not ride a deploy at all
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# CORRECTED 2026-08-30. This used to say marketplace nodes "CONVERGE at boot (keep-latest:
|
|
11
|
+
# the shared installed_plugins row... boot reinstalls anything missing or older — see
|
|
12
|
+
# runtime/plugins/startup.ts)". That module is deleted, along with the whole npm install
|
|
13
|
+
# lane; nothing is fetched from a registry (MARKETPLACE.md §10 q3). It also named
|
|
14
|
+
# plugin-base, retired to _legacy, and chained to deploy-packages.yml, which does not
|
|
15
|
+
# exist. Three things this playbook claimed on every deploy, none of them true.
|
|
16
|
+
#
|
|
17
|
+
# The CLI's `unoverse deploy` runs this. Use directly only when images alone changed.
|
|
13
18
|
#
|
|
14
19
|
# Usage (via CLI — recommended):
|
|
15
20
|
# unoverse deploy
|
|
@@ -71,10 +76,17 @@
|
|
|
71
76
|
debug:
|
|
72
77
|
msg: "{{ pull_result.stdout_lines[-8:] | default(['done']) }}"
|
|
73
78
|
|
|
74
|
-
|
|
79
|
+
# A DEPLOY TAKES THE NEWEST NODE RUNTIME. The entrypoint installs
|
|
80
|
+
# @unoverse-platform/base within the range the image declares, but it only goes to the
|
|
81
|
+
# registry when the volume is cold or the range moved — otherwise a restart would be a
|
|
82
|
+
# network call, and a restart must be instant and offline. A DEPLOY is the moment the
|
|
83
|
+
# operator asked for "make this current" (MARKETPLACE.md §5a), so it says so explicitly.
|
|
84
|
+
# Scoped to this one `up`, never written into .env: a later `docker restart` must not
|
|
85
|
+
# inherit it.
|
|
86
|
+
- name: "[3/4] Restart on the new images (and take the newest base in range)"
|
|
75
87
|
shell: |
|
|
76
88
|
cd {{ gravity_dir }}
|
|
77
|
-
docker compose up -d 2>&1 | tail -6
|
|
89
|
+
UNOVERSE_BASE_REFRESH=1 docker compose up -d 2>&1 | tail -6
|
|
78
90
|
register: up_result
|
|
79
91
|
|
|
80
92
|
# `up -d` RECREATES ON CONFIG CHANGE, NOT ON IMAGE CHANGE — not reliably, and not on
|
|
@@ -120,7 +132,8 @@
|
|
|
120
132
|
============================================
|
|
121
133
|
Host: {{ inventory_hostname }} ({{ ansible_host }})
|
|
122
134
|
{{ status_result.stdout }}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
Node manifests load at boot; component nodes
|
|
136
|
+
synthesize from design/ definitions.
|
|
137
|
+
Marketplace content is rows and is unchanged
|
|
138
|
+
by a deploy.
|
|
126
139
|
============================================
|
|
@@ -80,14 +80,18 @@
|
|
|
80
80
|
chdir: /opt/gravity
|
|
81
81
|
when: restore_umap
|
|
82
82
|
|
|
83
|
+
# THROUGH THE DOCKER NETWORK, not the host. umap publishes no port (docker-compose.yml,
|
|
84
|
+
# 2026-08-30: it is internal to Docker, full stop), so `http://localhost:5001` reaches
|
|
85
|
+
# nothing from here and this wait would spin for 60s and fail a good restore.
|
|
83
86
|
- name: Wait for UMAP to be healthy
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
shell: |
|
|
88
|
+
cd /opt/gravity
|
|
89
|
+
docker compose exec -T umap python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5001/health', timeout=5).status == 200 else 1)"
|
|
87
90
|
register: health_check
|
|
88
|
-
until: health_check.
|
|
91
|
+
until: health_check.rc == 0
|
|
89
92
|
retries: 30
|
|
90
93
|
delay: 2
|
|
94
|
+
changed_when: false
|
|
91
95
|
when: restore_umap
|
|
92
96
|
|
|
93
97
|
- name: Cleanup restore files
|
|
@@ -36,12 +36,14 @@
|
|
|
36
36
|
host: localhost
|
|
37
37
|
port: "{{ item.port }}"
|
|
38
38
|
timeout: 5
|
|
39
|
+
# UMAP is deliberately absent: it publishes no port to the host (internal to Docker
|
|
40
|
+
# only). Its reachability is proved by the Docker-DNS check below, which is the only
|
|
41
|
+
# thing that was ever actually load-bearing for it.
|
|
39
42
|
loop:
|
|
40
43
|
- { name: "Canvas", port: 3001 }
|
|
41
44
|
- { name: "Engine", port: 4101 }
|
|
42
45
|
- { name: "Unoverse", port: 4105 }
|
|
43
46
|
- { name: "Memory", port: 4104 }
|
|
44
|
-
- { name: "UMAP", port: 5001 }
|
|
45
47
|
register: port_checks
|
|
46
48
|
ignore_errors: yes
|
|
47
49
|
|
|
@@ -83,7 +85,6 @@
|
|
|
83
85
|
- { name: "Engine", port: 4101 }
|
|
84
86
|
- { name: "Unoverse", port: 4105 }
|
|
85
87
|
- { name: "Memory", port: 4104 }
|
|
86
|
-
- { name: "UMAP", port: 5001 }
|
|
87
88
|
register: health_checks
|
|
88
89
|
ignore_errors: yes
|
|
89
90
|
|
|
@@ -162,10 +163,12 @@
|
|
|
162
163
|
register: plugin_check
|
|
163
164
|
ignore_errors: yes
|
|
164
165
|
|
|
165
|
-
# NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount
|
|
166
|
-
#
|
|
166
|
+
# NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount retired when nodes
|
|
167
|
+
# became declarative YAML and marketplace content became rows. Nothing declares
|
|
167
168
|
# PACKAGES_PATH any more and docker-compose.yml has no such volume, so the check could
|
|
168
169
|
# only ever report 0 and tell the operator to run a command that would not fix it.
|
|
170
|
+
# (The original note said nodes "moved to npm at runtime"; that lane never survived and
|
|
171
|
+
# was removed outright on 2026-08-30. MARKETPLACE.md §10 q3.)
|
|
169
172
|
|
|
170
173
|
# SINCE THE SERVICES RESTARTED, NOT SINCE FIVE MINUTES AGO.
|
|
171
174
|
#
|
|
@@ -130,11 +130,35 @@ services:
|
|
|
130
130
|
# Studio is NOT a platform image (removed 2026-07-28): it ships from npm and
|
|
131
131
|
# connects to a universe over the API with a publish key.
|
|
132
132
|
|
|
133
|
-
# UMAP Service - Python ML service
|
|
133
|
+
# UMAP Service - Python ML service.
|
|
134
|
+
#
|
|
135
|
+
# THE ENGINE REACHES IT AS http://umap:5001 OVER THE DOCKER NETWORK, which needs no
|
|
136
|
+
# published port at all. It used to read "5001:5001", which binds 0.0.0.0 and put an ML
|
|
137
|
+
# service on the internet, and until 2026-08-30 that service also failed OPEN:
|
|
138
|
+
# its `require_auth` returned the handler unguarded whenever OIDC was unconfigured, and no
|
|
139
|
+
# AUTH_ISSUER was ever passed here. So `/train` answered anyone who could reach the port.
|
|
140
|
+
#
|
|
141
|
+
# It was the only service in this file that was both externally bound and ungated: 4101 and
|
|
142
|
+
# 4106 are loopback-pinned with comments saying never to widen them, and 4105 is published
|
|
143
|
+
# but JWT-gated. This one had neither half.
|
|
144
|
+
#
|
|
145
|
+
# NOT PUBLISHED AT ALL. Owner ruling 2026-08-30: this is internal to Docker, full stop.
|
|
146
|
+
# There is no `ports:` block below and that is the whole point — a service reachable only
|
|
147
|
+
# over the compose network cannot be bound to the wrong interface by a later edit, because
|
|
148
|
+
# there is no line to get wrong. 4101 and 4106 publish to loopback because a developer's
|
|
149
|
+
# tooling on the host genuinely needs them; nothing on the host needs this.
|
|
150
|
+
#
|
|
151
|
+
# Auth stays configured anyway. The network boundary and the gate are not alternatives,
|
|
152
|
+
# and this service is the proof: it had a `ports:` line and a fail-open decorator, so
|
|
153
|
+
# losing either one lost everything.
|
|
134
154
|
umap:
|
|
135
155
|
image: registry.digitalocean.com/gravity-repo/umap:latest
|
|
136
|
-
|
|
137
|
-
|
|
156
|
+
environment:
|
|
157
|
+
# app.py refuses to boot if auth is on and these are missing (assert_auth_startup),
|
|
158
|
+
# which is the point: an unconfigured deployment fails loudly instead of open.
|
|
159
|
+
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
|
160
|
+
- AUTH_ISSUER=${AUTH_ISSUER}
|
|
161
|
+
- AUTH_AUDIENCE=${AUTH_AUDIENCE}
|
|
138
162
|
volumes:
|
|
139
163
|
- umap_models:/app/models
|
|
140
164
|
logging: *default-logging
|
|
@@ -251,8 +275,20 @@ services:
|
|
|
251
275
|
# copy carrying a built dist. Without this the runtime hits MODULE_NOT_FOUND and
|
|
252
276
|
# the nodes show "Package not installed".
|
|
253
277
|
- NODE_PATH=/app/host_node_modules:/app/plugins/node_modules
|
|
278
|
+
# PASSTHROUGH, not a setting. `unoverse deploy` runs `UNOVERSE_BASE_REFRESH=1 docker
|
|
279
|
+
# compose up -d` to mean "take the newest node runtime in range"; without this line
|
|
280
|
+
# that variable would reach the compose CLI and stop there, never the container.
|
|
281
|
+
# Defaults to 0, so an ordinary restart stays offline and instant.
|
|
282
|
+
- UNOVERSE_BASE_REFRESH=${UNOVERSE_BASE_REFRESH:-0}
|
|
254
283
|
volumes:
|
|
255
284
|
- unoverse_plugins:/app/plugins
|
|
285
|
+
# THE NODE RUNTIME, which this image does not carry (MARKETPLACE.md §5a). The
|
|
286
|
+
# entrypoint installs @unoverse-platform/base here at startup, within the range
|
|
287
|
+
# apps/unoverse/package.json declares, and symlinks it into
|
|
288
|
+
# /app/node_modules/@unoverse-platform/base. A volume rather than a layer so a plain
|
|
289
|
+
# restart is offline and instant: the fetch happens on a cold volume, or when the
|
|
290
|
+
# declared range changes, or when a deploy sets UNOVERSE_BASE_REFRESH=1.
|
|
291
|
+
- unoverse_base:/app/base
|
|
256
292
|
# ── Platform-dev mounts (monorepo only; sync-starter strips these three
|
|
257
293
|
# lines from the starter's copy — a customer universe serves what the DB
|
|
258
294
|
# says). Local edits are picked up on restart; design/prompts are data, no build.
|
|
@@ -371,3 +407,4 @@ services:
|
|
|
371
407
|
volumes:
|
|
372
408
|
umap_models:
|
|
373
409
|
unoverse_plugins: # Marketplace-installed node packages (persisted across restarts)
|
|
410
|
+
unoverse_base: # @unoverse-platform/base, installed at startup (MARKETPLACE.md §5a)
|
package/operator/lib/check.sh
CHANGED
|
@@ -77,6 +77,32 @@ cmd_check() {
|
|
|
77
77
|
done
|
|
78
78
|
echo ""
|
|
79
79
|
|
|
80
|
+
# 1b. THE NODE RUNTIME THIS UNIVERSE IS ACTUALLY RUNNING.
|
|
81
|
+
#
|
|
82
|
+
# base is installed at startup, not baked into the image (MARKETPLACE.md §5a), so a
|
|
83
|
+
# universe's platform version and its runtime version are two different numbers that move
|
|
84
|
+
# independently. Nobody updates what they cannot see: this is the line that turns "which
|
|
85
|
+
# base am I on?" into something answerable, and shows the gap when the registry has newer.
|
|
86
|
+
# Read out of the RUNNING container rather than off this machine's disk, because the
|
|
87
|
+
# container is the only thing whose answer counts.
|
|
88
|
+
local base_installed base_range base_latest
|
|
89
|
+
base_installed=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
|
|
90
|
+
node -p "require('/app/node_modules/@unoverse-platform/base/package.json').version" 2>/dev/null | tr -d '\r')
|
|
91
|
+
if [ -n "$base_installed" ]; then
|
|
92
|
+
base_range=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
|
|
93
|
+
node -p "require('/app/apps/unoverse/package.json').dependencies['@unoverse-platform/base']" 2>/dev/null | tr -d '\r')
|
|
94
|
+
base_latest=$(curl -s --max-time 5 "https://registry.npmjs.org/-/package/@unoverse-platform%2Fbase/dist-tags" 2>/dev/null \
|
|
95
|
+
| node -pe 'try{JSON.parse(require("fs").readFileSync(0,"utf8")).latest ?? ""}catch(e){""}' 2>/dev/null)
|
|
96
|
+
if [ -n "$base_latest" ] && [ "$base_latest" != "$base_installed" ]; then
|
|
97
|
+
# NOT a failure. A universe is entitled to sit on an older runtime; it is not entitled
|
|
98
|
+
# to be unable to find out. `deploy` and `update` are what take the newer one.
|
|
99
|
+
warn "node runtime ${BOLD}$base_installed${NC} ${DIM}(range $base_range · npm has $base_latest — 'unoverse deploy' takes it)${NC}"
|
|
100
|
+
else
|
|
101
|
+
ok "node runtime ${BOLD}$base_installed${NC} ${DIM}(range ${base_range:-unknown})${NC}"
|
|
102
|
+
fi
|
|
103
|
+
echo ""
|
|
104
|
+
fi
|
|
105
|
+
|
|
80
106
|
# 2. Health endpoints
|
|
81
107
|
for endpoint in 4105:unoverse 4101:engine 5001:umap 4104:memory; do
|
|
82
108
|
local port="${endpoint%%:*}" name="${endpoint##*:}"
|
|
@@ -40,7 +40,8 @@ cmd_db_verify() {
|
|
|
40
40
|
],
|
|
41
41
|
workflow_executions: [
|
|
42
42
|
"execution_id", "workflow_id", "status", "start_time", "end_time",
|
|
43
|
-
"duration", "error", "result", "created_at", "was_from_cache", "source"
|
|
43
|
+
"duration", "error", "result", "created_at", "was_from_cache", "source",
|
|
44
|
+
"user_id"
|
|
44
45
|
],
|
|
45
46
|
node_traces: [
|
|
46
47
|
"trace_id", "execution_id", "node_id", "node_type", "start_time",
|
|
@@ -52,7 +53,7 @@ cmd_db_verify() {
|
|
|
52
53
|
],
|
|
53
54
|
token_usage: [
|
|
54
55
|
"id", "workflow_id", "execution_id", "node_id", "node_type",
|
|
55
|
-
"model", "usage", "timestamp", "created_at"
|
|
56
|
+
"model", "usage", "timestamp", "created_at", "user_id"
|
|
56
57
|
],
|
|
57
58
|
raw_messages: [
|
|
58
59
|
"id", "user_id", "workflow_id", "conversation_id", "chat_id",
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -892,9 +892,13 @@ EOF
|
|
|
892
892
|
case "$subcommand" in
|
|
893
893
|
""|deploy)
|
|
894
894
|
# THE deploy: the server takes the latest platform images (pull + restart).
|
|
895
|
-
# Content does NOT ride deploys — it arrives
|
|
896
|
-
#
|
|
897
|
-
#
|
|
895
|
+
# Content does NOT ride deploys — it arrives from the marketplace as database
|
|
896
|
+
# ROWS (`POST /marketplace/install`) or from `unoverse deploy studio`.
|
|
897
|
+
#
|
|
898
|
+
# "self-healing at boot" was the npm install lane (`plugins/startup.ts`),
|
|
899
|
+
# deleted 2026-08-30 — nothing is fetched from a registry (MARKETPLACE.md §10 q3).
|
|
900
|
+
# The one fetch that is coming is the node runtime, `@unoverse-platform/base`,
|
|
901
|
+
# installed here within a range the image declares (§5a, NOT YET BUILT).
|
|
898
902
|
info "Deploying platform images..."
|
|
899
903
|
echo ""
|
|
900
904
|
ansible-playbook \
|
package/operator/operator.sh
CHANGED
|
@@ -155,6 +155,12 @@ case "${1:-}" in
|
|
|
155
155
|
exit 1
|
|
156
156
|
fi
|
|
157
157
|
if [ -n "$(docker compose -f "$ROOT/docker-compose.yml" ps -q 2>/dev/null)" ]; then
|
|
158
|
+
# THE NODE RUNTIME UPDATES LIKE THE IMAGES DO. base is installed at startup, not
|
|
159
|
+
# baked (MARKETPLACE.md §5a), and the entrypoint only goes to the registry on a cold
|
|
160
|
+
# volume or a changed range — otherwise every restart would be a network call. An
|
|
161
|
+
# UPDATE is exactly the moment the operator asked for the newest of everything, so
|
|
162
|
+
# it says so. Scoped to this command; nothing is written to .env.
|
|
163
|
+
UNOVERSE_BASE_REFRESH=1 \
|
|
158
164
|
docker compose -f "$ROOT/docker-compose.yml" --env-file "$ROOT/.env" up -d --remove-orphans >/dev/null 2>&1 \
|
|
159
165
|
&& ok "Running services moved to the new images"
|
|
160
166
|
# MIGRATIONS RUN ON UPDATE, same as deploy and start. An update is exactly when a
|
package/package.json
CHANGED
|
@@ -93,9 +93,9 @@ export function collectProject(designRoot, project) {
|
|
|
93
93
|
// `course-card` or `hr-education-coach`, so the org must be in the name for the
|
|
94
94
|
// rows to coexist (the same trick a style row already uses: its name IS the org).
|
|
95
95
|
// A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
|
|
96
|
-
//
|
|
97
|
-
// (`<org>-chat
|
|
98
|
-
const QUALIFIED_KINDS = new Set(["component", "skill", "
|
|
96
|
+
// Apps keep their bare ids: those are org-qualified by convention
|
|
97
|
+
// (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
|
|
98
|
+
const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
|
|
99
99
|
const add = (kind, name, definition) => items.push({
|
|
100
100
|
kind,
|
|
101
101
|
name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
|
|
@@ -104,9 +104,9 @@ export function collectProject(designRoot, project) {
|
|
|
104
104
|
org: project,
|
|
105
105
|
base_version,
|
|
106
106
|
});
|
|
107
|
-
// `
|
|
107
|
+
// `apps` publishes as kind `app`, NOT `recipe`. A recipe is a workflow graph
|
|
108
108
|
// that is copied onto a canvas and never installed; an app is installed and tracked, so
|
|
109
|
-
// taking a newer version is the point rather than a hazard (
|
|
109
|
+
// taking a newer version is the point rather than a hazard (migrations 017 + 029).
|
|
110
110
|
//
|
|
111
111
|
// A DEFINITION IS EITHER A FOLDER OR A SINGLE FILE, and both are normal. A component
|
|
112
112
|
// that grew layouts and states is a folder; an atom is one file, and templates are a
|
|
@@ -115,11 +115,11 @@ export function collectProject(designRoot, project) {
|
|
|
115
115
|
//
|
|
116
116
|
// Both become the same shape, a map of relative path to contents, so nothing downstream
|
|
117
117
|
// has to care which it was. A single file is simply a map with one entry.
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
for (const [dir, kind] of [["components", "component"], ["apps", "
|
|
118
|
+
// APP MEANS APP, TEMPLATE MEANS TEMPLATE (owner ruling 2026-08-28, migration
|
|
119
|
+
// 029): apps publish as kind `app` and the Template Model kind owns the bare
|
|
120
|
+
// word. Forgetting a design dir here ships deploys that silently drop every
|
|
121
|
+
// artifact of that kind.
|
|
122
|
+
for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"]]) {
|
|
123
123
|
const home = join(root, dir);
|
|
124
124
|
if (!existsSync(home))
|
|
125
125
|
continue;
|
|
@@ -14,11 +14,39 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
16
16
|
import { join, dirname, basename, relative, sep } from "node:path";
|
|
17
|
-
import { RAW_VALUE, PARTIAL_DIRS } from "./vocabulary.mjs";
|
|
17
|
+
import { RAW_VALUE, PARTIAL_DIRS, PROP_KEYS, INTERFACE_FIELDS, AUTHORED_CONTROLS } from "./vocabulary.mjs";
|
|
18
18
|
import { isDefFile, defName, defPath, readDef, parseDef } from "./defs.mjs";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* THE PROPS MAP IS THE CONTRACT (UNOVERSE_AUTHORING §3b, ruled 2026-08-30). Every field's
|
|
22
|
+
* description, limits, mock and hydration live here now, so this is where they are held
|
|
23
|
+
* to a shape — and where a prop wearing an INTERFACE FIELD's name is told what happens
|
|
24
|
+
* when a delivery carries that field.
|
|
25
|
+
*/
|
|
26
|
+
function checkProps(props, file, report, path) {
|
|
27
|
+
if (!props || typeof props !== "object" || Array.isArray(props)) return;
|
|
28
|
+
for (const [name, spec] of Object.entries(props)) {
|
|
29
|
+
if (name.startsWith("_comment")) continue; // a JSON comment, not a prop
|
|
30
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) continue;
|
|
31
|
+
const where = path ? `${path}${name}` : name;
|
|
32
|
+
for (const k of Object.keys(spec))
|
|
33
|
+
if (!PROP_KEYS.includes(k))
|
|
34
|
+
report("error", file, `prop "${where}" has no "${k}". A prop reads ${PROP_KEYS.map((x) => `"${x}"`).join(", ")} — everything it needs to be understood and written (UNOVERSE_AUTHORING §3b)`);
|
|
35
|
+
// THE DELIVERY WINS. A row fills a component by name with no mapping layer
|
|
36
|
+
// (docs.unoverse.ai/design/interface-data), so `title` on a content-attached card is
|
|
37
|
+
// the row's title. Using the name is right and expected; writing a brief for a field
|
|
38
|
+
// the delivery supplies is the part that quietly never applies.
|
|
39
|
+
if (!path && INTERFACE_FIELDS.has(name)) {
|
|
40
|
+
const controls = AUTHORED_CONTROLS.filter((k) => spec[k] !== undefined);
|
|
41
|
+
if (controls.length)
|
|
42
|
+
report("warn", file, `prop "${name}" is an INTERFACE FIELD, so a delivery fills it by name — and a row's value is not measured against the ${controls.map((c) => `"${c}"`).join(", ")} written here. Keep the name (binding is by name, never mapped); the limit applies only to a value an Agent writes (docs.unoverse.ai/design/interface-data)`);
|
|
43
|
+
}
|
|
44
|
+
if (spec.items && typeof spec.items === "object") checkProps(spec.items, file, report, `${where}.items.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
export function makeLintFile(ctx) {
|
|
21
|
-
const { report, walkNode, isFixture, isHook, isManifest, isAppPath, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
|
|
49
|
+
const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
|
|
22
50
|
|
|
23
51
|
function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
24
52
|
if (!Array.isArray(order)) return;
|
|
@@ -42,6 +70,86 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
|
|
|
42
70
|
report("warn", file, `states/${name}.json is not in stateOrder. It falls to the end of the picker; add it to lock the order (docs.unoverse.ai/design/studio)`);
|
|
43
71
|
}
|
|
44
72
|
|
|
73
|
+
|
|
74
|
+
// THE APP TREE CHECK (extracted 2026-08-29: the tree's home is the ENVELOPE —
|
|
75
|
+
// one folder grammar — while a manifest-held tree remains the legacy read, so
|
|
76
|
+
// both call sites validate identically).
|
|
77
|
+
function checkAppTree(json, root, file) {
|
|
78
|
+
if (json.states !== undefined) {
|
|
79
|
+
if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
|
|
80
|
+
report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
|
|
81
|
+
} else {
|
|
82
|
+
const names = Object.keys(json.states);
|
|
83
|
+
const base = json.layout ?? Object.keys(json.states)[0] ?? "main";
|
|
84
|
+
if (!names.includes(base))
|
|
85
|
+
report("error", file, `the template tree must include the BASE state "${base}" (named for the default layout) — the ladder derives as the top level minus it (STATE_MODEL §5)`);
|
|
86
|
+
const subs = [];
|
|
87
|
+
for (const [n, s] of Object.entries(json.states)) {
|
|
88
|
+
if (s !== null && (typeof s !== "object" || Array.isArray(s)))
|
|
89
|
+
report("error", file, `tree state "${n}" must be an object declaring its layout path (STATE_MODEL §5)`);
|
|
90
|
+
// EXPLICIT PATHS (owner ruling 2026-08-29): every state DECLARES the
|
|
91
|
+
// layout it draws as a root-relative path (layout: layouts/<file>);
|
|
92
|
+
// nothing is assumed from the state's name and no folder is magic.
|
|
93
|
+
const declared = s && typeof s === "object" && typeof s.layout === "string" ? s.layout : undefined;
|
|
94
|
+
if (!declared)
|
|
95
|
+
report("error", file, `tree state "${n}" declares no layout. Every state names its drawing as a path — layout: layouts/${n} (2026-08-29 ruling; the same-name default is retired) (STATE_MODEL §5)`);
|
|
96
|
+
else if (!defPath(root, declared))
|
|
97
|
+
report("error", file, `tree state "${n}" declares layout "${declared}" but no such file exists (STATE_MODEL §5)`);
|
|
98
|
+
const nested = s && typeof s === "object" ? s.states : undefined;
|
|
99
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested))
|
|
100
|
+
for (const [sub, sv] of Object.entries(nested)) {
|
|
101
|
+
subs.push(sub);
|
|
102
|
+
const subDeclared = sv && typeof sv === "object" && typeof sv.layout === "string" ? sv.layout : undefined;
|
|
103
|
+
if (!subDeclared)
|
|
104
|
+
report("error", file, `substate "${sub}" (under "${n}") declares no layout. Every state names its drawing as a path — layout: layouts/${n}-${sub} (the owner-prefix naming convention survives as readability) (2026-08-29 ruling) (LAYERS §3)`);
|
|
105
|
+
else if (!defPath(root, subDeclared))
|
|
106
|
+
report("error", file, `substate "${sub}" (under "${n}") declares layout "${subDeclared}" but no such file exists (LAYERS §3)`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const sub of subs)
|
|
110
|
+
if (names.includes(sub))
|
|
111
|
+
report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
|
|
112
|
+
// THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
|
|
113
|
+
// either a SCREEN (a bare name declaring a top-level state) or a substate
|
|
114
|
+
// drawing (<owner>-<sub>, both declared). Anything else is an error — a
|
|
115
|
+
// shared part belongs in components/.
|
|
116
|
+
{
|
|
117
|
+
const ldir = join(root, "layouts");
|
|
118
|
+
if (existsSync(ldir)) {
|
|
119
|
+
const declaredSubs = new Map();
|
|
120
|
+
for (const [n2, s2] of Object.entries(json.states)) {
|
|
121
|
+
const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
|
|
122
|
+
if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
|
|
123
|
+
for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
|
|
124
|
+
}
|
|
125
|
+
// A file NAMED by any state's/substate's declared path is accounted
|
|
126
|
+
// for wherever it sits (2026-08-29: declarations, not names, link).
|
|
127
|
+
const declaredPaths = new Set();
|
|
128
|
+
for (const s3 of Object.values(json.states)) {
|
|
129
|
+
if (s3 && typeof s3 === "object" && typeof s3.layout === "string") declaredPaths.add(s3.layout);
|
|
130
|
+
const nested3 = s3 && typeof s3 === "object" ? s3.states : undefined;
|
|
131
|
+
if (nested3 && typeof nested3 === "object" && !Array.isArray(nested3))
|
|
132
|
+
for (const sv3 of Object.values(nested3))
|
|
133
|
+
if (sv3 && typeof sv3 === "object" && typeof sv3.layout === "string") declaredPaths.add(sv3.layout);
|
|
134
|
+
}
|
|
135
|
+
for (const f of readdirSync(ldir).filter(isDefFile)) {
|
|
136
|
+
const nm = defName(f);
|
|
137
|
+
if (declaredPaths.has(`layouts/${nm}`)) continue;
|
|
138
|
+
if (names.includes(nm) || declaredSubs.has(nm)) continue;
|
|
139
|
+
const owner = names.find((n2) => nm.startsWith(`${n2}-`));
|
|
140
|
+
if (owner)
|
|
141
|
+
report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
|
|
142
|
+
else
|
|
143
|
+
report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (json.stateOrder !== undefined)
|
|
148
|
+
report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
|
|
149
|
+
}
|
|
150
|
+
} else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
|
|
151
|
+
}
|
|
152
|
+
|
|
45
153
|
// ── lint one file ──
|
|
46
154
|
function lintFile(file) {
|
|
47
155
|
// `readText` is disk unless the caller supplied an overlay for this path (index.mjs).
|
|
@@ -87,6 +195,7 @@ function lintFile(file) {
|
|
|
87
195
|
|
|
88
196
|
if (isFixture(file)) return; // legacy fixture, unused: don't choke
|
|
89
197
|
if (isHook(file)) return; // a lifecycle hook: calls + a projection, not a UI tree
|
|
198
|
+
if (isPropsExtract(file)) return; // the envelope's props map, composed there — not a UI tree
|
|
90
199
|
|
|
91
200
|
if (isManifest(file)) {
|
|
92
201
|
const root = dirname(file);
|
|
@@ -106,9 +215,19 @@ function lintFile(file) {
|
|
|
106
215
|
const firstState = json.states && typeof json.states === "object" && !Array.isArray(json.states)
|
|
107
216
|
? Object.keys(json.states)[0]
|
|
108
217
|
: undefined;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
218
|
+
// The base state's DECLARED path wins (2026-08-29 ruling: layout is a
|
|
219
|
+
// root-relative path, nothing assumed); the name fallback survives only
|
|
220
|
+
// for the unswept installed tier.
|
|
221
|
+
const firstEntry = firstState ? json.states[firstState] : undefined;
|
|
222
|
+
const declaredBase = firstEntry && typeof firstEntry === "object" && typeof firstEntry.layout === "string" ? firstEntry.layout : undefined;
|
|
223
|
+
if (declaredBase) {
|
|
224
|
+
if (!defPath(root, declaredBase))
|
|
225
|
+
report("error", file, `base state "${firstState}" declares layout "${declaredBase}" but no such file exists (docs.unoverse.ai/design/apps)`);
|
|
226
|
+
} else {
|
|
227
|
+
const layoutName = json.layout ?? firstState ?? "main";
|
|
228
|
+
if (!defPath(join(root, "layouts"), layoutName))
|
|
229
|
+
report("error", file, `manifest.layout "${layoutName}" → layouts/${layoutName} does not exist (and no <name> envelope) (docs.unoverse.ai/design/apps)`);
|
|
230
|
+
}
|
|
112
231
|
}
|
|
113
232
|
// THE TEMPLATE TREE (STATE_MODEL v2, checkpoint 2026-08-08): a manifest
|
|
114
233
|
// `states:` block declares the whole machine — validate the DECLARATION:
|
|
@@ -123,59 +242,7 @@ function lintFile(file) {
|
|
|
123
242
|
// into layouts/ and update $include paths.
|
|
124
243
|
if (existsSync(join(root, "states")))
|
|
125
244
|
report("error", file, `template carries a states/ folder — templates have ONE drawings folder. Move states/* into layouts/ and update every "$include: states/<name>" to "layouts/<name>" (anatomy unified with components, LAYERS §3)`);
|
|
126
|
-
|
|
127
|
-
if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
|
|
128
|
-
report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
|
|
129
|
-
} else {
|
|
130
|
-
const names = Object.keys(json.states);
|
|
131
|
-
const base = json.layout ?? Object.keys(json.states)[0] ?? "main";
|
|
132
|
-
if (!names.includes(base))
|
|
133
|
-
report("error", file, `the template tree must include the BASE state "${base}" (named for the default layout) — the ladder derives as the top level minus it (STATE_MODEL §5)`);
|
|
134
|
-
const subs = [];
|
|
135
|
-
for (const [n, s] of Object.entries(json.states)) {
|
|
136
|
-
if (s !== null && (typeof s !== "object" || Array.isArray(s)))
|
|
137
|
-
report("error", file, `tree state "${n}" must be an object ({} is a complete state) (STATE_MODEL §5)`);
|
|
138
|
-
if (n !== base && !defPath(join(root, "layouts"), n))
|
|
139
|
-
report("error", file, `tree state "${n}" has no layouts/${n}. Every state's drawing — a full arrangement or a stacked overlay — lives in layouts/, the template's ONE drawings folder (anatomy unified with components, LAYERS §3) (STATE_MODEL §5)`);
|
|
140
|
-
const nested = s && typeof s === "object" ? s.states : undefined;
|
|
141
|
-
if (nested && typeof nested === "object" && !Array.isArray(nested))
|
|
142
|
-
for (const sub of Object.keys(nested)) {
|
|
143
|
-
subs.push(sub);
|
|
144
|
-
if (!defPath(join(root, "layouts"), `${n}-${sub}`))
|
|
145
|
-
report("error", file, `substate "${sub}" (under "${n}") has no layouts/${n}-${sub} file — a substate's drawing is OWNER-PREFIXED in layouts/ (the step-* convention: bare names are the app's screens, <owner>-<sub> their sub-drawings) (LAYERS §3)`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (const sub of subs)
|
|
149
|
-
if (names.includes(sub))
|
|
150
|
-
report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
|
|
151
|
-
// THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
|
|
152
|
-
// either a SCREEN (a bare name declaring a top-level state) or a substate
|
|
153
|
-
// drawing (<owner>-<sub>, both declared). Anything else is an error — a
|
|
154
|
-
// shared part belongs in components/.
|
|
155
|
-
{
|
|
156
|
-
const ldir = join(root, "layouts");
|
|
157
|
-
if (existsSync(ldir)) {
|
|
158
|
-
const declaredSubs = new Map();
|
|
159
|
-
for (const [n2, s2] of Object.entries(json.states)) {
|
|
160
|
-
const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
|
|
161
|
-
if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
|
|
162
|
-
for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
|
|
163
|
-
}
|
|
164
|
-
for (const f of readdirSync(ldir).filter(isDefFile)) {
|
|
165
|
-
const nm = defName(f);
|
|
166
|
-
if (names.includes(nm) || declaredSubs.has(nm)) continue;
|
|
167
|
-
const owner = names.find((n2) => nm.startsWith(`${n2}-`));
|
|
168
|
-
if (owner)
|
|
169
|
-
report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
|
|
170
|
-
else
|
|
171
|
-
report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (json.stateOrder !== undefined)
|
|
176
|
-
report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
|
|
177
|
-
}
|
|
178
|
-
} else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
|
|
245
|
+
checkAppTree(json, root, file);
|
|
179
246
|
// ONE STATE AT A TIME (docs.unoverse.ai/design/state): the active state is derived from the
|
|
180
247
|
// latest surfaced VIEW, so no two surfaces in one template may claim the same
|
|
181
248
|
// view — the active surface would be ambiguous.
|
|
@@ -185,7 +252,7 @@ function lintFile(file) {
|
|
|
185
252
|
if (Array.isArray(n)) return n.forEach((c) => collectClaims(c, from));
|
|
186
253
|
if (!n || typeof n !== "object") return;
|
|
187
254
|
const w = n.type === "ComponentSlot" ? n.select?.where : null;
|
|
188
|
-
if ((w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
|
|
255
|
+
if ((w?.field === "state" || w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
|
|
189
256
|
if (claims.has(w.eq))
|
|
190
257
|
report("error", file, `two reaction surfaces claim the view "${w.eq}" (${claims.get(w.eq)} and ${from}). A template is in ONE state at a time; each view has exactly one surface (docs.unoverse.ai/design/state)`);
|
|
191
258
|
else claims.set(w.eq, from);
|
|
@@ -227,9 +294,16 @@ function lintFile(file) {
|
|
|
227
294
|
// The resolver already looks both ways (definitions.ts, template tree); without
|
|
228
295
|
// the same lookup here a correctly-migrated template lints as though every one
|
|
229
296
|
// of its substates had vanished.
|
|
230
|
-
|
|
231
|
-
|
|
297
|
+
// The tree's home is the ENVELOPE (one folder grammar, 2026-08-29); a
|
|
298
|
+
// manifest-held tree is the legacy read.
|
|
299
|
+
const envPath = defPath(root, basename(root));
|
|
300
|
+
const envStates = envPath ? readDef(envPath)?.states : undefined;
|
|
301
|
+
const treeStates = (json.states && typeof json.states === "object" && !Array.isArray(json.states) ? json.states : undefined)
|
|
302
|
+
?? (envStates && typeof envStates === "object" && !Array.isArray(envStates) ? envStates : {});
|
|
303
|
+
for (const [parent, node] of Object.entries(treeStates)) {
|
|
304
|
+
states.add(parent);
|
|
232
305
|
for (const sub of Object.keys(node?.states ?? {})) if (states.has(`${parent}-${sub}`)) states.add(sub);
|
|
306
|
+
}
|
|
233
307
|
const comps = componentNamesForFile(file);
|
|
234
308
|
for (const [state, list] of Object.entries(json.preview)) {
|
|
235
309
|
if (!states.has(state))
|
|
@@ -283,39 +357,104 @@ function lintFile(file) {
|
|
|
283
357
|
const root = defRoot(file);
|
|
284
358
|
|
|
285
359
|
if (isEnvelope) {
|
|
286
|
-
// COMPONENT envelope (templates have no envelope — their manifest is it).
|
|
287
360
|
// The declaration word is `type:`; `kind:` is the legacy spelling, still read.
|
|
361
|
+
// ONE FOLDER GRAMMAR (owner ruling 2026-08-29): every kind has an envelope
|
|
362
|
+
// saying what it IS. component/atom = root; template = parts+shapes; app =
|
|
363
|
+
// the states tree (its manifest stays the MCP face).
|
|
288
364
|
const declared = json.type ?? json.kind;
|
|
289
|
-
|
|
365
|
+
// Template grammar §9b (2026-08-29): states like every kind; parts/shapes = legacy.
|
|
366
|
+
// ONE REQUIREMENT SHAPE (2026-08-29): a kind declares its tree (`states:`) OR
|
|
367
|
+
// its drawing (`root` — flat components/atoms). A tree component's root is
|
|
368
|
+
// SYNTHESIZED by the compiler, never required.
|
|
369
|
+
const envelopeRequired =
|
|
370
|
+
declared === "app" || declared === "template" ? ["name", "states"]
|
|
371
|
+
: declared === "component" ? ["name", ...(json.states !== undefined || json.state?.view !== undefined ? [] : ["root"])]
|
|
372
|
+
: ["name", "root"];
|
|
373
|
+
if (declared === "template" && json.states === undefined && (json.parts !== undefined || json.shapes !== undefined)) envelopeRequired.pop();
|
|
374
|
+
for (const req of envelopeRequired)
|
|
290
375
|
if (json[req] === undefined) report("error", file, `envelope missing "${req}" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
291
376
|
if (declared === undefined) report("error", file, `envelope missing "type" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
292
377
|
if (json.kind !== undefined && json.type === undefined)
|
|
293
378
|
report("warn", file, `"kind" is the legacy spelling of the declaration — write "type: ${json.kind}"`);
|
|
294
|
-
if (declared && !["component", "template", "atom"].includes(declared))
|
|
379
|
+
if (declared && !["component", "template", "atom", "app"].includes(declared))
|
|
295
380
|
report("error", file, `unknown type "${declared}"`);
|
|
296
381
|
if (declared === "component" && !json.category)
|
|
297
382
|
report("warn", file, `component has no "category". Used to group it in the palette (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
383
|
+
// APP envelope: the states tree lives HERE (one folder grammar) — validate it
|
|
384
|
+
// exactly as the legacy manifest-held tree was.
|
|
385
|
+
if (declared === "app") checkAppTree(json, root, file);
|
|
298
386
|
if (json.root) walkNode(json.root, file, root);
|
|
299
387
|
|
|
300
388
|
// ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──
|
|
301
389
|
if (declared === "component") {
|
|
302
|
-
|
|
390
|
+
// A TEMPLATE'S OWN PART IS A FLAT COMPONENT (TEMPLATE_MODEL §9b rule 7): one file,
|
|
391
|
+
// no manifest, no state tree — the face IS the filename. Its `root` here is the
|
|
392
|
+
// TEMPLATE's folder, which does carry `layouts/`, so without this the whole faced-
|
|
393
|
+
// component discipline fires on every part and demands a Switch it must not have.
|
|
394
|
+
const hasLayouts = !isTemplatePart(file) && existsSync(join(root, "layouts"));
|
|
303
395
|
const statesDir = join(root, "states");
|
|
304
396
|
const stateFiles = existsSync(statesDir)
|
|
305
397
|
? readdirSync(statesDir).filter(isDefFile).map(defName).sort()
|
|
306
398
|
: [];
|
|
307
399
|
const hasStateBlock = json.state && typeof json.state === "object";
|
|
308
400
|
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
401
|
+
// THE DISCOVERY MANIFEST IS THE SINGLE HOME for the meta — but the envelope's own
|
|
402
|
+
// `description` is now COMPOSITION CONTEXT (UNOVERSE_AUTHORING §3b, 2026-08-30):
|
|
403
|
+
// what an Agent filling the WHOLE of this must know, which used to ride an unbound
|
|
404
|
+
// `brief` on the layout root. Two files, two audiences. Only a COPY of the
|
|
405
|
+
// manifest's text is the duplication this rule exists to stop.
|
|
406
|
+
{
|
|
407
|
+
const mPath = defPath(root, "manifest");
|
|
408
|
+
let m = null;
|
|
409
|
+
if (mPath) { try { m = readDef(mPath); } catch { m = null; } }
|
|
410
|
+
if (m) {
|
|
411
|
+
if (json.whenToUse !== undefined)
|
|
412
|
+
report("error", file, `envelope duplicates manifest meta "whenToUse". The discovery manifest is the single home (docs.unoverse.ai/design/components)`);
|
|
413
|
+
if (typeof json.description === "string" && json.description.trim() === String(m.description ?? "").trim())
|
|
414
|
+
report("error", file, `the envelope's "description" is a copy of the manifest's. The manifest is the single home for the DISCOVERY line; the envelope's own description is composition context — what an Agent filling the whole of this must know (UNOVERSE_AUTHORING §3b)`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
314
417
|
|
|
315
418
|
// deprecated bridge: a top-level `defaultState` triggers the component node APP_DATA emit
|
|
316
419
|
if (json.defaultState !== undefined)
|
|
317
420
|
report("warn", file, `top-level "defaultState" is the deprecated bridge (component-node APP_DATA emit). The master state lives in the \`state\` block; templates react via ComponentSlot.select.where (STATE_MODEL §5b)`);
|
|
318
421
|
|
|
422
|
+
// THE PROP IS THE BRIEF (UNOVERSE_AUTHORING §3b, 2026-08-30): a prop declares
|
|
423
|
+
// everything about itself, so the props map is the whole contract and is held to a
|
|
424
|
+
// closed shape. The props extract composes first (`props: { $include: props }` —
|
|
425
|
+
// the loader does the same before any reader sees the map).
|
|
426
|
+
let judgedProps = json.props ?? {};
|
|
427
|
+
if (typeof judgedProps.$include === "string") {
|
|
428
|
+
const incPath = defPath(root, judgedProps.$include);
|
|
429
|
+
if (!incPath) {
|
|
430
|
+
report("error", file, `props $include "${judgedProps.$include}" resolves to no file in the definition folder (docs.unoverse.ai/design/components)`);
|
|
431
|
+
judgedProps = {};
|
|
432
|
+
} else {
|
|
433
|
+
try { judgedProps = readDef(incPath) ?? {}; } catch { judgedProps = {}; }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
checkProps(judgedProps, file, report, "");
|
|
437
|
+
|
|
438
|
+
// EVERY PROP CARRIES A DESCRIPTION (UNOVERSE_AUTHORING §3b, RULED 2026-08-30).
|
|
439
|
+
//
|
|
440
|
+
// It reads BOTH ways, which is why it is compulsory rather than encouraged. A
|
|
441
|
+
// description is not only what a copywriter fills against: it is what lets an agent
|
|
442
|
+
// UNDERSTAND the screen it is looking at. `read` reports where the person is; the
|
|
443
|
+
// descriptions say what is actually in front of them. A screen of unnamed fields is
|
|
444
|
+
// one no agent can reason about, in either direction.
|
|
445
|
+
//
|
|
446
|
+
// AND IT SAYS NOTHING ABOUT PERMISSION. That is the layout's word — `static` /
|
|
447
|
+
// `copywriter` / `director` (TEMPLATE_MODEL §9b) — never a prop's. The rule this
|
|
448
|
+
// replaces was "a described prop is a writable prop", which made silence the safety
|
|
449
|
+
// mechanism: a field was protected because nobody had explained it, and explaining it
|
|
450
|
+
// was what exposed it. Describing a field is now free of consequence, so there is no
|
|
451
|
+
// longer any reason to leave one blank.
|
|
452
|
+
const undescribed = Object.entries(judgedProps)
|
|
453
|
+
.filter(([, v]) => !(v && typeof v === "object" && typeof v.description === "string" && v.description.trim()))
|
|
454
|
+
.map(([k]) => k);
|
|
455
|
+
if (undescribed.length)
|
|
456
|
+
report("error", file, `props [${undescribed.join(", ")}] carry no "description". Every prop says what it IS, so an agent can read the screen as well as fill it. A description grants nothing: permission is the layout's word (static / copywriter / director), never a prop's (UNOVERSE_AUTHORING §3b)`);
|
|
457
|
+
|
|
319
458
|
// only components that ADOPTED the structure are held to the full discipline
|
|
320
459
|
if (hasLayouts || stateFiles.length || hasStateBlock) {
|
|
321
460
|
// Every prop declares what fills it. Was "must be input:true", from when props
|
|
@@ -323,12 +462,15 @@ function lintFile(file) {
|
|
|
323
462
|
// preview axis, literals passed via `Ref with`) — input:true there is false AND
|
|
324
463
|
// load-bearing, since configSchema derives from it. Silence is the error:
|
|
325
464
|
// unflagged props default to INPUTS.
|
|
326
|
-
|
|
465
|
+
// The props extract composes first (`props: { $include: props }` — the loader
|
|
466
|
+
// does the same before any reader sees the map).
|
|
467
|
+
const undeclared = Object.entries(judgedProps)
|
|
327
468
|
.filter(([, v]) => !(v && typeof v === "object" && typeof v.input === "boolean"))
|
|
328
469
|
.map(([k]) => k);
|
|
329
470
|
if (undeclared.length)
|
|
330
471
|
report("error", file, `microapp props [${undeclared.join(", ")}] do not declare "input". Every prop says what fills it: input:true = a workflow streams it (it joins the node's configSchema); input:false = nothing does (a preview axis, or a literal the host passes via Ref with). Unflagged props default to INPUTS (docs.unoverse.ai/design/components)`);
|
|
331
472
|
|
|
473
|
+
|
|
332
474
|
// STATE MODEL v2 (UNOVERSE_STATE_MODEL §5): an authored `state.view` TREE is
|
|
333
475
|
// the component's state machine — the ONE object the scalar rule admits.
|
|
334
476
|
// Well-formed = { initial?: string, states: { <name>: { layout?/layouts?/on?/
|
|
@@ -344,38 +486,53 @@ function lintFile(file) {
|
|
|
344
486
|
(s.layout === undefined || typeof s.layout === "string"),
|
|
345
487
|
);
|
|
346
488
|
};
|
|
347
|
-
|
|
489
|
+
// THE RULED FORM (2026-08-29): the tree lives at TOP-LEVEL `states:` like
|
|
490
|
+
// every kind; `state.view` is the legacy wrapper, still read.
|
|
491
|
+
const topTree = json.states && typeof json.states === "object" && !Array.isArray(json.states) && isViewTree({ states: json.states }) ? { states: json.states } : null;
|
|
492
|
+
const viewTree = topTree ?? (hasStateBlock && isViewTree(json.state.view) ? json.state.view : null);
|
|
348
493
|
|
|
349
494
|
// the state block holds SCALAR internal view-state ONLY — an array/object (a
|
|
350
495
|
// finder's result rows) or a URL is content/data slop: hardcode it in the layout,
|
|
351
496
|
// or move workflow-fed data to props (input:true) (AUTHORING §3). The one
|
|
352
497
|
// exception: a well-formed v2 `state.view` tree (STATE_MODEL §5 rule 1).
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
498
|
+
const scalarHomes = [];
|
|
499
|
+
if (hasStateBlock) scalarHomes.push(["state", json.state]);
|
|
500
|
+
if (json.values && typeof json.values === "object" && !Array.isArray(json.values)) scalarHomes.push(["values", json.values]);
|
|
501
|
+
for (const [home, blk] of scalarHomes)
|
|
502
|
+
for (const [k, v] of Object.entries(blk)) {
|
|
503
|
+
if (home === "state" && k === "view" && viewTree && !topTree) continue; // legacy wrapper only
|
|
356
504
|
if (Array.isArray(v) || (v && typeof v === "object"))
|
|
357
|
-
report("error", file,
|
|
505
|
+
report("error", file, `${home}.${k} is an ${Array.isArray(v) ? "array" : "object"}. The values block is SCALAR starting values only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs.unoverse.ai/design/components)`);
|
|
358
506
|
else if (typeof v === "string" && /^https?:\/\//.test(v))
|
|
359
|
-
report("error", file,
|
|
507
|
+
report("error", file, `${home}.${k} is a URL. Content, not a starting value; hardcode it in the layout (or props input:true if workflow-fed) (docs.unoverse.ai/design/components)`);
|
|
360
508
|
}
|
|
361
509
|
|
|
362
510
|
if (hasLayouts) {
|
|
363
511
|
const raw = JSON.stringify(json.root ?? {}).replace(/\s/g, "");
|
|
364
|
-
if (viewTree) {
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
512
|
+
if (viewTree && json.root == null) {
|
|
513
|
+
// THE TREE IS THE MACHINE (2026-08-29 late): no authored root — the
|
|
514
|
+
// compiler synthesizes the Switch from the tree. Nothing to check.
|
|
515
|
+
} else if (viewTree) {
|
|
516
|
+
// An AUTHORED root is a real shell (beyond the plain Switch) — it must
|
|
517
|
+
// still switch the public axis. A root that merely restates the tree is
|
|
518
|
+
// redundant: delete it and let the compiler synthesize.
|
|
519
|
+
if ((!raw.includes('"on":"state"') && !raw.includes('"on":"view"')) || !/"\$include":"layouts\//.test(raw))
|
|
520
|
+
report("error", file, `a tree component's authored root must Switch on "state" (the ruled public-axis word; "view" is the legacy spelling) → $include layouts/<layout> (STATE_MODEL §5)`);
|
|
521
|
+
const rootObj = json.root ?? {};
|
|
522
|
+
const kids = Array.isArray(rootObj.children) ? rootObj.children : [];
|
|
523
|
+
if (rootObj.type === "Box" && kids.length === 1 && kids[0]?.type === "Switch")
|
|
524
|
+
report("warn", file, `the root is only the tree's Switch restated — delete it; the compiler synthesizes the root from \`states:\` (2026-08-29 ruling)`);
|
|
368
525
|
} else if (!raw.includes('"on":"defaultState"') || !/"\$include":"layouts\//.test(raw))
|
|
369
526
|
report("error", file, `a faced component's root must Switch on defaultState → $include layouts/<state> (legacy; or declare a v2 state.view tree and Switch on "view") (docs.unoverse.ai/design/components)`);
|
|
370
527
|
|
|
371
528
|
// ── face set ⇄ layouts/ cross-check (OPEN name set — inline/focused/<any>) ──
|
|
372
529
|
// The FACES are the root Switch's cases; Studio's face toggle and the render
|
|
373
530
|
// path both derive from them, so cases and layout files must agree exactly.
|
|
374
|
-
const
|
|
531
|
+
const axes = viewTree ? ["state", "view"] : ["defaultState"];
|
|
375
532
|
const findFaceSwitch = (n) => {
|
|
376
533
|
if (!n || typeof n !== "object") return null;
|
|
377
534
|
if (Array.isArray(n)) { for (const c of n) { const r = findFaceSwitch(c); if (r) return r; } return null; }
|
|
378
|
-
if (n.type === "Switch" && n.on
|
|
535
|
+
if (n.type === "Switch" && axes.includes(n.on) && n.cases && typeof n.cases === "object") return n;
|
|
379
536
|
for (const v of Object.values(n)) { const r = findFaceSwitch(v); if (r) return r; }
|
|
380
537
|
return null;
|
|
381
538
|
};
|
|
@@ -414,8 +571,13 @@ function lintFile(file) {
|
|
|
414
571
|
const layoutName = inc.slice("layouts/".length);
|
|
415
572
|
usedLayouts.add(layoutName);
|
|
416
573
|
// v2: the STATE owns its layout — the tree's declaration decides which
|
|
417
|
-
// file draws it
|
|
418
|
-
|
|
574
|
+
// file draws it. Declarations are root-relative PATHS (2026-08-29
|
|
575
|
+
// ruling); a bare name still reads as layouts/<name> for the unswept
|
|
576
|
+
// installed tier.
|
|
577
|
+
const declaredLayout = viewTree?.states?.[name]?.layout;
|
|
578
|
+
const expected = declaredLayout
|
|
579
|
+
? (declaredLayout.includes("/") ? declaredLayout : `layouts/${declaredLayout}`).replace(/^layouts\//, "")
|
|
580
|
+
: name;
|
|
419
581
|
if (name !== "default" && layoutName !== expected)
|
|
420
582
|
report("error", file, viewTree
|
|
421
583
|
? `state "${name}" declares layout "${expected}" but its case includes layouts/${layoutName} — the tree's declaration and the case must agree (STATE_MODEL §5 rule 1)`
|
|
@@ -424,12 +586,16 @@ function lintFile(file) {
|
|
|
424
586
|
// v2: tree-declared layouts (including nested substates' and variants') are
|
|
425
587
|
// reachable by declaration — seed them so the orphan check knows them.
|
|
426
588
|
if (viewTree) {
|
|
589
|
+
// Declared values are PATHS (2026-08-29); the orphan set is keyed by
|
|
590
|
+
// layouts/-relative names, so strip the prefix (bare legacy names
|
|
591
|
+
// pass through unchanged).
|
|
592
|
+
const asName = (l) => l.replace(/^layouts\//, "");
|
|
427
593
|
const seed = (states) => {
|
|
428
594
|
for (const s of Object.values(states ?? {})) {
|
|
429
595
|
if (!s || typeof s !== "object") continue;
|
|
430
|
-
if (typeof s.layout === "string") usedLayouts.add(s.layout);
|
|
596
|
+
if (typeof s.layout === "string") usedLayouts.add(asName(s.layout));
|
|
431
597
|
if (s.layouts && typeof s.layouts === "object")
|
|
432
|
-
for (const l of Object.values(s.layouts)) if (typeof l === "string") usedLayouts.add(l);
|
|
598
|
+
for (const l of Object.values(s.layouts)) if (typeof l === "string") usedLayouts.add(asName(l));
|
|
433
599
|
if (s.states) seed(s.states);
|
|
434
600
|
}
|
|
435
601
|
};
|
|
@@ -480,7 +646,7 @@ function lintFile(file) {
|
|
|
480
646
|
for (const lf of readdirSync(d).filter(isDefFile)) {
|
|
481
647
|
try {
|
|
482
648
|
if (/"key":"defaultState"/.test(JSON.stringify(readDef(join(d, lf)))))
|
|
483
|
-
report("warn", file, `${sub}/${lf} writes setValue "defaultState" but this component declares a
|
|
649
|
+
report("warn", file, `${sub}/${lf} writes setValue "defaultState" but this component declares a tree — write "state" (the ruled word; "view"/"defaultState" are legacy) (STATE_MODEL §2)`);
|
|
484
650
|
} catch { /* that file lints separately */ }
|
|
485
651
|
}
|
|
486
652
|
}
|
|
@@ -163,8 +163,33 @@ const isHook = (f) => {
|
|
|
163
163
|
const stem = basename(f).replace(/\.(json|ya?ml)$/, "").toLowerCase();
|
|
164
164
|
return life.some((e) => typeof e === "object" && e && typeof e.handler === "string" && e.handler.toLowerCase() === stem);
|
|
165
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
|
+
};
|
|
166
178
|
const isManifest = (f) => /^manifest\.(json|yaml)$/.test(basename(f));
|
|
167
|
-
|
|
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");
|
|
168
193
|
// The DEFINITION ROOT folder for a file: a bare partial lives one level under it
|
|
169
194
|
// (layouts/ states/ components/ blocks/); every `$include` resolves against it.
|
|
170
195
|
const defRoot = (file) => {
|
|
@@ -424,6 +449,22 @@ for (const orgDir of orgDirs) {
|
|
|
424
449
|
}
|
|
425
450
|
orgComponentNames.set(basename(orgDir), set);
|
|
426
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
|
+
for (const root of [DS, ...orgDirs]) {
|
|
458
|
+
const home = join(root, "templates");
|
|
459
|
+
if (!existsSync(home)) continue;
|
|
460
|
+
for (const t of readdirSync(home)) {
|
|
461
|
+
const parts = join(home, t, "components");
|
|
462
|
+
if (!existsSync(parts)) continue;
|
|
463
|
+
const set = templatePartNames.get(t.toLowerCase()) ?? new Set();
|
|
464
|
+
for (const e of readdirSync(parts)) if (isDefFile(e)) set.add(defName(e).toLowerCase());
|
|
465
|
+
templatePartNames.set(t.toLowerCase(), set);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
427
468
|
const orgOfFile = (file) => {
|
|
428
469
|
const home = orgDirs.find((d) => file.startsWith(d + sep));
|
|
429
470
|
return home ? basename(home) : null;
|
|
@@ -436,9 +477,12 @@ const refResolves = (ref, file) => {
|
|
|
436
477
|
const fileOrg = file ? orgOfFile(file) : null;
|
|
437
478
|
const slash = lower.indexOf("/");
|
|
438
479
|
if (slash > 0) {
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
480
|
+
const qualifier = lower.slice(0, slash);
|
|
481
|
+
const name = lower.slice(slash + 1);
|
|
482
|
+
// A template's own part, composing another part of the same template.
|
|
483
|
+
if (templatePartNames.get(qualifier)?.has(name)) return true;
|
|
484
|
+
if (qualifier !== fileOrg) return false; // another org's, or a DS file naming any org
|
|
485
|
+
return orgComponentNames.get(qualifier)?.has(name) ?? false;
|
|
442
486
|
}
|
|
443
487
|
if (atomNames.has(lower) || dsComponentNames.has(lower)) return true;
|
|
444
488
|
return fileOrg ? (orgComponentNames.get(fileOrg)?.has(lower) ?? false) : false;
|
|
@@ -564,11 +608,19 @@ for (const [dir, isDir] of keyHomes) {
|
|
|
564
608
|
}
|
|
565
609
|
}
|
|
566
610
|
|
|
567
|
-
// definition homes: marketplace components/atoms + each org's
|
|
568
|
-
// components (same law everywhere — an org component is a component,
|
|
569
|
-
|
|
611
|
+
// definition homes: the marketplace's components/atoms/templates + each org's apps,
|
|
612
|
+
// components and templates (same law everywhere — an org component is a component,
|
|
613
|
+
// just org-private).
|
|
614
|
+
//
|
|
615
|
+
// TEMPLATES ARE A KIND, AND A KIND IS LINTED (2026-08-30). The Template Model's
|
|
616
|
+
// `templates/` folders were never added here, so the entire kind was invisible: an
|
|
617
|
+
// invented token or an off-scale space step inside `<org>/templates/**` reported clean,
|
|
618
|
+
// while the same file under `components/` threw two errors. A home that is not
|
|
619
|
+
// enumerated is a home where every rule is off, silently — and the parts a template
|
|
620
|
+
// carries are ordinary components, so they were never exempt from anything.
|
|
621
|
+
const homes = [{ dir: join(DS, "components") }, { dir: join(DS, "atoms") }, { dir: join(DS, "templates") }];
|
|
570
622
|
for (const orgDir of orgDirs) {
|
|
571
|
-
for (const sub of ["apps", "components"]) {
|
|
623
|
+
for (const sub of ["apps", "components", "templates"]) {
|
|
572
624
|
const d = join(orgDir, sub);
|
|
573
625
|
if (existsSync(d) && statSync(d).isDirectory()) homes.push({ dir: d });
|
|
574
626
|
}
|
|
@@ -855,12 +907,24 @@ const componentNamesCache = new Map();
|
|
|
855
907
|
// Components an ORG's template may reference: the marketplace tier + that org's OWN
|
|
856
908
|
// components — never another org's (org-privacy). Cached per org.
|
|
857
909
|
function componentNamesForFile(file) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
910
|
+
// THE PROJECT A FILE BELONGS TO, from the path below the design root. This read
|
|
911
|
+
// `${sep}orgs${sep}` and returned null for anything else, so on the flat layout — which
|
|
912
|
+
// is every project now (`design/<org>/…`, "flat wins") — it answered "no list" and
|
|
913
|
+
// every rule that asks it, preview names included, silently stopped checking.
|
|
914
|
+
const segs = relative(DESIGN_ROOT, file).split(sep);
|
|
915
|
+
if (segs.length < 2 || segs[0] === "..") return null;
|
|
916
|
+
const projectSegs = segs[0] === "orgs" ? segs.slice(0, 2) : segs.slice(0, 1);
|
|
917
|
+
const org = projectSegs.join("/");
|
|
861
918
|
const cached = componentNamesCache.get(org);
|
|
862
919
|
if (cached !== undefined) return cached;
|
|
863
|
-
const dirs = [
|
|
920
|
+
const dirs = [
|
|
921
|
+
join(DS, "components"),
|
|
922
|
+
join(DESIGN_ROOT, ...projectSegs, "components"),
|
|
923
|
+
// TEMPLATES ARE PEERS (§9c): a state that holds one seeds it by name like any
|
|
924
|
+
// component, so the name set a preview is checked against includes them.
|
|
925
|
+
join(DS, "templates"),
|
|
926
|
+
join(DESIGN_ROOT, ...projectSegs, "templates"),
|
|
927
|
+
];
|
|
864
928
|
let names = null;
|
|
865
929
|
for (const dir of dirs) {
|
|
866
930
|
if (!existsSync(dir)) continue;
|
|
@@ -877,7 +941,7 @@ function componentNamesForFile(file) {
|
|
|
877
941
|
const ctx = {
|
|
878
942
|
DESIGN_ROOT, DS, orgDirs, report, spaceSteps, stepList, checkDimension, checkToken, checkCondition,
|
|
879
943
|
appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists,
|
|
880
|
-
isFixture, isHook, isManifest, isAppPath, defRoot, readText,
|
|
944
|
+
isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePath, isTemplatePart, defRoot, readText,
|
|
881
945
|
};
|
|
882
946
|
const walkNode = makeWalkNode(ctx);
|
|
883
947
|
const lintFile = makeLintFile({ ...ctx, walkNode });
|
|
@@ -33,10 +33,44 @@ export const CONDITION_KEYS = new Set(["field", "eq", "ne", "in"]);
|
|
|
33
33
|
* Schema halves, so the editor squiggle and the publish gate cannot disagree.
|
|
34
34
|
*/
|
|
35
35
|
export const UNIVERSAL_PROPS = new Set([
|
|
36
|
-
// read for every node before dispatch
|
|
37
|
-
|
|
36
|
+
// read for every node before dispatch. `brief` is GONE (UNOVERSE_AUTHORING §3b, ruled
|
|
37
|
+
// 2026-08-30): a field's contract lives on its prop, and the layout only arranges.
|
|
38
|
+
"type", "style", "visibleWhen", "bind", "action", "analytics", "skeleton", "$include",
|
|
38
39
|
]);
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A PROP, whole (UNOVERSE_AUTHORING §3b): JSON Schema's own words plus the platform's
|
|
43
|
+
* four. Closed, because the prop is now the only place a field's contract can live —
|
|
44
|
+
* a misspelled `maxlength` there is a limit that silently never applies.
|
|
45
|
+
*/
|
|
46
|
+
export const PROP_KEYS = [
|
|
47
|
+
"type", "input", "description", "maxLength", "minItems", "maxItems",
|
|
48
|
+
"hydrate", "optional", "enum", "preview", "default", "items",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* THE INTERFACE FIELDS (packages/docs/design/interface-data.md): the names a content row
|
|
53
|
+
* carries. A row fills a component BY NAME with no mapping layer, so a prop wearing one
|
|
54
|
+
* of these names is filled by the delivery — whatever the definition declares about it.
|
|
55
|
+
* Layer 1 arrives with every card; the `service`/`need` editorial fields arrive with the
|
|
56
|
+
* row that has them; layer 2 arrives when `getDetail` runs.
|
|
57
|
+
*/
|
|
58
|
+
export const INTERFACE_FIELDS = new Set([
|
|
59
|
+
"universal_id", "title", "description", "object_type", "key_need", "source_url", "source_id",
|
|
60
|
+
"tagline", "shortDescription", "introParagraph", "callToAction", "actionPrompt", "primaryImage", "images", "action",
|
|
61
|
+
"bodyCopy", "features", "mainCategory", "section", "needs",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The keys that only make sense if an AUTHOR, not a delivery, decides the value — a cap,
|
|
66
|
+
* a count, a closed set. `description` is deliberately NOT one: every prop describes
|
|
67
|
+
* itself as standard (UNOVERSE_AUTHORING §3b), including the ones a row fills, so
|
|
68
|
+
* flagging a described `title` would fire on every content card in the estate and teach
|
|
69
|
+
* people to ignore the rule. What is worth saying is narrower and still true: a LIMIT you
|
|
70
|
+
* wrote does not apply to a value the delivery brings.
|
|
71
|
+
*/
|
|
72
|
+
export const AUTHORED_CONTROLS = ["maxLength", "minItems", "maxItems", "enum"];
|
|
73
|
+
|
|
40
74
|
export const PRIMITIVE_PROPS = {
|
|
41
75
|
Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth"],
|
|
42
76
|
Stack: ["children", "autoScroll", "revealOnStick", "appWidth"],
|
|
@@ -17,8 +17,14 @@ import { join, dirname, basename, relative, sep } from "node:path";
|
|
|
17
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
18
|
import { isDefFile, defName, defPath, readDef } from "./defs.mjs";
|
|
19
19
|
|
|
20
|
+
// THE THREE WORDS and the director's closed block (UNOVERSE_TEMPLATE_MODEL.md §9b).
|
|
21
|
+
const SECTION_WORDS = ["static", "copywriter", "director"];
|
|
22
|
+
const DIRECTOR_KEYS = ["state", "limit", "rules", "preview"];
|
|
23
|
+
|
|
20
24
|
export function makeWalkNode(ctx) {
|
|
21
|
-
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, defRoot } = ctx;
|
|
25
|
+
const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, defRoot } = ctx;
|
|
26
|
+
// One director per LAYOUT FILE, tracked for this run only (each file is walked once).
|
|
27
|
+
const directorsSeen = new Map();
|
|
22
28
|
|
|
23
29
|
function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
24
30
|
if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
|
|
@@ -33,6 +39,81 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
33
39
|
return; // the included file is linted on its own
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// THE THREE WORDS (UNOVERSE_TEMPLATE_MODEL.md §9b): a template layout's parts say HOW
|
|
43
|
+
// they are filled instead of naming a primitive. They compile to ComponentSlots at
|
|
44
|
+
// serve time, so the linter checks the authored word here and hands the rest of the
|
|
45
|
+
// node to the ordinary walk as the slot it becomes.
|
|
46
|
+
const word = SECTION_WORDS.find((w) => node[w] !== undefined);
|
|
47
|
+
if (word && node.type === undefined) {
|
|
48
|
+
if (!isTemplatePath(file))
|
|
49
|
+
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)`);
|
|
50
|
+
if (word === "director") {
|
|
51
|
+
const d = node.director;
|
|
52
|
+
if (!d || typeof d !== "object" || Array.isArray(d))
|
|
53
|
+
report("error", file, `"director:" is a block, not a path — no component is ever named there: it is the one part whose content nobody knows at design time (§9b rule 1)`);
|
|
54
|
+
else {
|
|
55
|
+
for (const k of Object.keys(d))
|
|
56
|
+
if (!DIRECTOR_KEYS.includes(k))
|
|
57
|
+
report("error", file, `director has no "${k}". It reads ${DIRECTOR_KEYS.map((x) => `"${x}"`).join(", ")} (§9b)`);
|
|
58
|
+
if (d.limit !== undefined && typeof d.limit !== "number")
|
|
59
|
+
report("error", file, `director.limit is a NUMBER — a hard cap the machine enforces, never trusted to the model (§9b)`);
|
|
60
|
+
if (d.rules !== undefined && typeof d.rules !== "string")
|
|
61
|
+
report("error", file, `director.rules is written like a brief: one string of English (§9b)`);
|
|
62
|
+
if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
|
|
63
|
+
report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
|
|
64
|
+
else if (Array.isArray(d.preview)) {
|
|
65
|
+
const comps = componentNamesForFile(file);
|
|
66
|
+
for (const c of d.preview)
|
|
67
|
+
if (comps && !comps.has(String(c).toLowerCase()))
|
|
68
|
+
report("error", file, `director.preview names unknown component "${c}". No match in the design system or this org's components (lookup is case-insensitive)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ONE MIND, MANY BANDS (§9b rule 3, amended 2026-08-30): a layout may hold several
|
|
72
|
+
// directed bands — one verdict, arranged — but each must claim a DISTINCT face, or
|
|
73
|
+
// the surface is ambiguous (the reaction contract's one-view-one-surface rule). So
|
|
74
|
+
// on a multi-band layout every director declares its `state:`; two bands claiming
|
|
75
|
+
// the same face is the error this rule now exists for.
|
|
76
|
+
const seen = directorsSeen.get(file) ?? [];
|
|
77
|
+
const face = typeof d?.state === "string" ? d.state : null;
|
|
78
|
+
if (seen.length && !face)
|
|
79
|
+
report("error", file, `a second "director:" in this layout declares no "state:". Several bands are allowed — one mind, arranged — but each must claim a DISTINCT face, so every one of them names it (§9b rule 3)`);
|
|
80
|
+
else if (face && seen.includes(face))
|
|
81
|
+
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)`);
|
|
82
|
+
directorsSeen.set(file, [...seen, face]);
|
|
83
|
+
} else if (typeof node[word] !== "string") {
|
|
84
|
+
report("error", file, `"${word}:" links a component by PATH (${word}: components/<part>) — that is what makes it a known part (§9b rule 1)`);
|
|
85
|
+
} else {
|
|
86
|
+
const path = node[word];
|
|
87
|
+
if (!defPath(root, path) && !existsSync(join(root, path)))
|
|
88
|
+
report("error", file, `${word} "${path}" does not resolve under ${relative(process.cwd(), root)}/ (§9b rule 1)`);
|
|
89
|
+
}
|
|
90
|
+
// Everything else on the line rides through to the slot it compiles into. The select
|
|
91
|
+
// is SYNTHESIZED here exactly as the compiler builds it (a linked part claims its own
|
|
92
|
+
// face, limit 1; the director's claim is its `state:` pin) so the rest of the walk
|
|
93
|
+
// sees what will actually be served — and an authored one is the ceremony §9b rule 9
|
|
94
|
+
// exists to abolish.
|
|
95
|
+
if (node.select !== undefined)
|
|
96
|
+
report("error", file, `a template part never authors "select" — the three words compile to the wire primitives at serve time (§9b rule 9)`);
|
|
97
|
+
const { static: _s, copywriter: _c, director: _d, select: _sel, ...rest } = node;
|
|
98
|
+
// An unpinned director claims the OWNING STATE's name (arrivals keep their own state
|
|
99
|
+
// and the template's states react by name — the free grid↔page dance), so the face
|
|
100
|
+
// comes from whichever state in the envelope declares this layout.
|
|
101
|
+
const owningState = () => {
|
|
102
|
+
const env = defPath(root, basename(root));
|
|
103
|
+
const states = env ? readDef(env)?.states : undefined;
|
|
104
|
+
if (!states || typeof states !== "object") return undefined;
|
|
105
|
+
const here = defName(basename(file));
|
|
106
|
+
return Object.keys(states).find((s) => {
|
|
107
|
+
const l = states[s]?.layout;
|
|
108
|
+
return typeof l === "string" && basename(l).toLowerCase() === here.toLowerCase();
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
const face = word === "director" ? (node.director?.state ?? owningState()) : String(node[word]).split("/").pop();
|
|
112
|
+
const limit = word === "director" ? node.director?.limit : 1;
|
|
113
|
+
const select = { from: "all", ...(face ? { where: { field: "state", eq: face } } : {}), ...(typeof limit === "number" ? { limit } : {}) };
|
|
114
|
+
return walkNode({ type: "ComponentSlot", select, ...rest }, file, root, widthCap);
|
|
115
|
+
}
|
|
116
|
+
|
|
36
117
|
const t = node.type;
|
|
37
118
|
if (typeof t !== "string")
|
|
38
119
|
report("error", file, `node without "type" (and no $include). Every node names a primitive (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
|
|
@@ -136,12 +217,12 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
|
|
|
136
217
|
// PUBLIC state — the `view` axis (`defaultState` = legacy alias) — never to a
|
|
137
218
|
// component's internal state (step/phase/…) which is private to the component.
|
|
138
219
|
// Selecting on any other field reaches past the boundary the whole model rests on.
|
|
139
|
-
else if (node.select.where && node.select.where.field && node.select.where.field !== "view" && node.select.where.field !== "defaultState")
|
|
140
|
-
report("warn", file, `reaction surface selects on "${node.select.where.field}". A template reacts to a component's PUBLIC state ("
|
|
220
|
+
else if (node.select.where && node.select.where.field && node.select.where.field !== "state" && node.select.where.field !== "view" && node.select.where.field !== "defaultState")
|
|
221
|
+
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)`);
|
|
141
222
|
// ONE STATE AT A TIME: the template is in exactly one state, so a surface must
|
|
142
223
|
// claim exactly ONE view by `eq` — `ne`/`in`/bare selects make "which state is
|
|
143
224
|
// the template in?" ambiguous.
|
|
144
|
-
else if (node.select.where && (node.select.where.field === "view" || node.select.where.field === "defaultState") && typeof node.select.where.eq !== "string")
|
|
225
|
+
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")
|
|
145
226
|
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)`);
|
|
146
227
|
}
|
|
147
228
|
|