unoverse 0.1.186 → 0.1.187

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.186",
3
+ "version": "0.1.187",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -45,6 +45,46 @@ function checkProps(props, file, report, path) {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * THE DISCOVERY META CONTRACT (docs.unoverse.ai/nodes/node-discoverability).
50
+ * Four fields, one job each, and the SAME rules for a component, a template and an app:
51
+ * all three are ranked against what a PERSON said, so an app gets these errors rather
52
+ * than the warnings it used to get.
53
+ *
54
+ * The embedded text is "<title>. <whenToUse> <description>" (runtime/bootstrap.ts), so a
55
+ * missing title means the def name, or the folder slug, is the first token the ranker
56
+ * sees. That is a warning rather than an error: the fallback is fine when the name
57
+ * already reads as the thing.
58
+ */
59
+ const SELECTOR_SHAPED = /\b(pick when|pick whenever|pick for|use (this|when)|when the (user|customer|learner|caller) (asks|wants|says|needs|expresses)|select (this|when))\b/i;
60
+ const MECHANISM_WORDS = /(two-column|\blayout\b|componentslot|\bstreamed\b|\bskeleton\b|\bfull-height\b|\bshell\b)/i;
61
+
62
+ function checkDiscoveryMeta(report, file, json, kind, docs) {
63
+ const desc = typeof json.description === "string" ? json.description.trim() : "";
64
+ if (desc.length < 20)
65
+ report("error", file, `discovery manifest.description missing/too short. One line (\u226520 chars) saying what the ${kind} IS (${docs})`);
66
+ else if (desc.length > 120)
67
+ report("error", file, `discovery manifest.description is ${desc.length} chars: it's the listing subtitle (\u2264120); move detail into whenToUse (${docs})`);
68
+ if (/\buse when\b/i.test(desc))
69
+ report("error", file, `discovery manifest.description smuggles "use when\u2026". Description says what it IS; routing text belongs in whenToUse (${docs})`);
70
+
71
+ const wtu = typeof json.whenToUse === "string" ? json.whenToUse.trim() : "";
72
+ if (wtu.length < 20)
73
+ report("error", file, `discovery manifest.whenToUse missing/too short. The utterance-shaped selection text findIntent ranks on (${docs})`);
74
+ else {
75
+ if (SELECTOR_SHAPED.test(wtu))
76
+ report("error", file, `discovery manifest.whenToUse is selector-shaped. Write the words the USER would say, not instructions about the user (${docs})`);
77
+ const mech = wtu.match(MECHANISM_WORDS);
78
+ if (mech)
79
+ report("error", file, `discovery manifest.whenToUse leads with mechanism ("${mech[0]}"), so it ranks near layout vocabulary instead of the job. Outcome first (${docs})`);
80
+ }
81
+
82
+ if (!(typeof json.title === "string" && json.title.trim()))
83
+ report("warn", file, `discovery manifest has no "title". The embedding is "<title>. <whenToUse> <description>", so the name or the folder slug leads it instead (${docs})`);
84
+ if (!(typeof json.category === "string" && json.category.trim()))
85
+ report("warn", file, `discovery manifest has no "category". Name the domain of the job, never the implementation (${docs})`);
86
+ }
87
+
48
88
  export function makeLintFile(ctx) {
49
89
  const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
50
90
 
@@ -201,8 +241,8 @@ function lintFile(file) {
201
241
  const root = dirname(file);
202
242
  if (isAppPath(file)) {
203
243
  // TEMPLATE manifest = the envelope. Requires binding + a resolvable root.
204
- for (const req of ["name", "whenToUse"])
205
- if (!json[req]) report("warn", file, `template manifest missing "${req}": ${req === "whenToUse" ? "the AI selects the app by it" : "the display name"} (docs.unoverse.ai/design/apps)`);
244
+ if (!json.name) report("warn", file, `app manifest missing "name": the display name (docs.unoverse.ai/design/apps)`);
245
+ checkDiscoveryMeta(report, file, json, "app", "docs.unoverse.ai/design/apps");
206
246
  if (!(json.binding && json.binding.workflow))
207
247
  report("warn", file, `template manifest has no binding.workflow. The app owns its workflow binding (docs.unoverse.ai/design/apps)`);
208
248
  // Two valid roots (definitions.ts:229): the STANDARD manifest-only form (root =
@@ -331,16 +371,9 @@ function lintFile(file) {
331
371
  } else {
332
372
  // COMPONENT manifest = OPTIONAL spatial discovery. No binding. Mirrors the
333
373
  // discovery-meta assertions in server/src/runtime/microapp-structure.test.ts.
334
- const desc = typeof json.description === "string" ? json.description.trim() : "";
335
- if (desc.length < 20)
336
- report("error", file, `discovery manifest.description missing/too short. One line (≥20 chars) saying what the component IS (docs.unoverse.ai/design/components)`);
337
- else if (desc.length > 120)
338
- report("error", file, `discovery manifest.description is ${desc.length} chars: it's the listing subtitle (≤120); move detail into whenToUse (docs.unoverse.ai/design/components)`);
339
- const wtu = typeof json.whenToUse === "string" ? json.whenToUse.trim() : "";
340
- if (wtu.length < 20)
341
- report("error", file, `discovery manifest.whenToUse missing/too short. The utterance-shaped selection text findIntent ranks on (docs.unoverse.ai/design/components)`);
342
- else if (/\b(pick when|use (this|when)|when the user|the user (asks|wants|needs)|select (this|when))\b/i.test(wtu))
343
- report("error", file, `discovery manifest.whenToUse is selector-shaped. Write the words the USER would say, not instructions about the user (docs.unoverse.ai/design/components)`);
374
+ const isTpl = /[\\/]templates[\\/]/.test(file);
375
+ checkDiscoveryMeta(report, file, json, isTpl ? "template" : "component",
376
+ isTpl ? "docs.unoverse.ai/design/templates" : "docs.unoverse.ai/design/components");
344
377
  if (json.binding)
345
378
  report("warn", file, `a component discovery manifest has no workflow. Drop "binding" (a component is streamed or node-hydrated) (docs.unoverse.ai/design/components)`);
346
379
  // `lifetime` — OPTIONAL render lifetime (docs.unoverse.ai/design/state §Two lifetimes). Closed set:
@@ -683,6 +683,67 @@ for (const orgDir of orgDirs) {
683
683
  );
684
684
  }
685
685
 
686
+ /**
687
+ * ── the LATCH, on a state (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) ──
688
+ *
689
+ * Declaring it makes whatever wears that face ADDRESSABLE: the pill goes up and the guest's
690
+ * next sentences travel to that instance instead of opening a new answer. Every finding here
691
+ * is something that produces a pill which looks fine and behaves wrongly, which is why they
692
+ * are errors rather than warnings.
693
+ *
694
+ * Read from the ENVELOPE first and the manifest second, the same order the loader reads the
695
+ * states tree in — a rule that judged only one home would pass whatever was authored in the
696
+ * other.
697
+ */
698
+ const LATCH_KEYS = new Set(["title", "icon"]);
699
+ for (const orgDir of orgDirs) {
700
+ const adir = join(orgDir, "apps");
701
+ if (!(existsSync(adir) && statSync(adir).isDirectory())) continue;
702
+ for (const e of readdirSync(adir)) {
703
+ const folder = join(adir, e);
704
+ let src, states;
705
+ try {
706
+ if (!statSync(folder).isDirectory()) continue;
707
+ // The envelope (`<name>.yaml`) is the app tree's home; the manifest is the legacy one.
708
+ const envelope = defPath(folder, e);
709
+ const manifest = defPath(folder, "manifest");
710
+ for (const p of [envelope, manifest]) {
711
+ if (!p || !existsSync(p)) continue;
712
+ const s = readDef(p).states;
713
+ if (s && typeof s === "object" && !Array.isArray(s)) {
714
+ src = p;
715
+ states = s;
716
+ break;
717
+ }
718
+ }
719
+ } catch {
720
+ continue; // a malformed envelope is reported elsewhere
721
+ }
722
+ if (!states) continue;
723
+ for (const [sname, sentry] of Object.entries(states)) {
724
+ const latch = sentry && typeof sentry === "object" && !Array.isArray(sentry) ? sentry.latch : undefined;
725
+ if (latch === undefined) continue;
726
+ if (typeof latch !== "object" || latch === null || Array.isArray(latch)) {
727
+ 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)`);
728
+ continue;
729
+ }
730
+ // The pill is the ONLY sign the guest has that their words are landing on this page
731
+ // rather than the conversation. Unlabelled, it says nothing — and the compiler drops
732
+ // a titleless block, so the state would silently stop being addressable.
733
+ if (typeof latch.title !== "string" || !latch.title.trim())
734
+ 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")`);
735
+ const extra = Object.keys(latch).filter((k) => !LATCH_KEYS.has(k));
736
+ if (extra.length)
737
+ 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`);
738
+ // A STATE NOTHING CAN ENTER CANNOT BE ADDRESSED. The base state is the app at rest —
739
+ // the conversation itself — and `stateOrder` excludes it, so `resolveLatch` can never
740
+ // rank a base-state instance and the pill would never appear.
741
+ if (sname === Object.keys(states)[0])
742
+ 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`);
743
+ }
744
+ }
745
+ }
746
+
686
747
  // ── lifecycle declarations (the ONE sanctioned code carve-out) ──
687
748
  // A component may run server-side code at a platform fire point, and the safety of that
688
749
  // rests on the manifest and the thing that runs agreeing. Both halves are checked here so
@@ -691,7 +752,6 @@ for (const orgDir of orgDirs) {
691
752
  const KNOWN_LIFECYCLES = new Set(["onStart", "onEnterView"]);
692
753
  const PHASES_WITH_LAYOUTS = new Set(["onEnterView"]); // phases that fire per VIEW
693
754
  const PLATFORM_HANDLERS = new Set(["getDetail"]); // named handlers needing no file
694
- const LATCH_KEYS = new Set(["title", "background", "color"]);
695
755
 
696
756
  // Every credential DEFINITION this universe can offer a form for. They ship with node
697
757
  // packages (nodes/<pkg>/credentials/<name>.yaml); a component NAMES one, it never defines
@@ -782,61 +842,23 @@ for (const orgDir of [DS, ...orgDirs]) {
782
842
  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`);
783
843
 
784
844
  /**
785
- * LATCH (docs/MCP_COMPLETE_GUIDE.md §The Component Latch). Declaring it makes the
786
- * component addressable: the pill goes up and the guest's next sentences travel to THIS
787
- * instance. Every finding here is something that produces a pill which looks fine and
788
- * behaves wrongly, which is why they are errors rather than warnings.
845
+ * LATCH, RETIRED FROM THE MANIFEST (2026-08-31, MCP guide §The Component Latch).
846
+ *
847
+ * It moved to the STATE, in the app's own tree, beside that state's `layout:` and
848
+ * `preview:`. A component said one thing about itself for the whole of its life, but a
849
+ * page is addressable when it IS the page and not when it is a summary in the flow, and
850
+ * only the state knows which of those is on screen.
851
+ *
852
+ * An ERROR rather than a silent ignore: nothing reads this key any more, so a manifest
853
+ * still carrying it declares an intent that never happens, and the author has no way to
854
+ * tell that from a latch that simply does not work.
789
855
  */
790
- let latch;
791
- try {
792
- latch = readDef(mf).latch;
793
- } catch {
794
- latch = undefined; // malformed manifest is reported elsewhere
795
- }
796
- if (latch !== undefined) {
797
- if (typeof latch !== "object" || latch === null || Array.isArray(latch)) {
798
- report("error", mf, `"latch" must be a block ({ title, icon, background, color }), not ${Array.isArray(latch) ? "a list" : typeof latch}. Presence is what makes a component latchable, so a malformed one is an intent nothing can honour (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`);
799
- } else {
800
- // The pill is the ONLY sign the guest has that their words are landing on this
801
- // component rather than the conversation. Unlabelled, it says nothing.
802
- if (typeof latch.title !== "string" || !latch.title.trim())
803
- report("error", mf, `"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 ("Booking", not "Edit booking")`);
804
-
805
- const extra = Object.keys(latch).filter((k) => !LATCH_KEYS.has(k));
806
- if (extra.length)
807
- report("error", mf, `"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`);
808
-
809
- // Same law as every other surface: tokens, never hex. ABSTAINS where the token set
810
- // cannot be read, exactly as checkToken does — judging against half a set rejects
811
- // correct work.
812
- const T = tokensForFile(mf);
813
- if (T) {
814
- for (const k of ["background", "color"])
815
- if (typeof latch[k] === "string" && latch[k].trim() && !T.color.has(latch[k]))
816
- report("error", mf, `latch.${k}: "${latch[k]}" is not a colour token. Unknown names reach CSS verbatim and are dropped, so the pill renders unstyled with no error anywhere. Known: ${[...T.color].sort().join(", ") || "none"} (docs.unoverse.ai/design/styles-and-tokens)`);
817
- }
818
-
819
- /**
820
- * TWO CONTRADICTORY DECLARATIONS. A latch says "the conversation is now ABOUT this
821
- * instance", which is a conversation-scoped relationship; `lifetime: turn` (the
822
- * default) says the instance is done being the subject at the end of this turn.
823
- * One manifest cannot mean both.
824
- *
825
- * NOT because typing wipes it — it does not. The new-turn reset (`beginExchange`)
826
- * is GONE (store.ts, the seven rules, rule 7: "typing changes NOTHING by itself"),
827
- * so a turn-lifetime page survives the guest's next message and a latch on it
828
- * appears to work. It comes apart LATER and conditionally: `supersede` retracts a
829
- * non-durable slice when another component arrives into the SAME state, and
830
- * `cancelBelow` / `closeSurfaces` release it on a higher arrival or a guest close.
831
- * Each skips `lifetime: "conversation"` and nothing else. The pill is derived from
832
- * the slice, which survives the retract, so it stays up pointing at a page that has
833
- * left the surface. Intermittent, silent, and indistinguishable from the latch
834
- * "just not working" — which is why it is caught here rather than lived with.
835
- */
836
- if (readDef(mf).lifetime !== "conversation")
837
- report("error", mf, `"latch" needs "lifetime: conversation". A latch makes the conversation ABOUT this instance, which outlives a turn; the default turn lifetime says the opposite. Concretely: another component arriving into this component's state retracts it to inline (store.ts supersede) while the pill — derived from the surviving slice — stays up, so the guest goes on addressing a page that has left the surface (docs/MCP_COMPLETE_GUIDE.md §The Component Latch)`);
838
- }
839
- }
856
+ if (readDef(mf).latch !== undefined)
857
+ report(
858
+ "error",
859
+ mf,
860
+ `"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)`,
861
+ );
840
862
  }
841
863
  }
842
864