synthesisui 0.16.76 → 0.16.78

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * THE ANATOMY THE SKILL READ, TURNED INTO SOMETHING THE SYSTEM CAN HOLD.
3
+ *
4
+ * The first version of this contract asked for the parts as a FLAT LIST and threw
5
+ * away the structure the skill had just finished reading. So an `ArticleCard`
6
+ * with a 16:9 cover, a title, a full editor and a footer of five buttons came
7
+ * back as four labels in a row - and the owner compared it to the real component
8
+ * and saw a broken version of it. It was not a broken version. It was not a
9
+ * version of anything (dono, 01/08).
10
+ *
11
+ * THE THREE FRONTIERS, and knowing which one a node is is the whole job:
12
+ *
13
+ * a PART an element of theirs it has styles, and we draw it
14
+ * a COMPONENT a component of theirs it has a recipe of its own, so it
15
+ * renders as a NAMED BLOCK and becomes
16
+ * a rule about the two of them
17
+ * an EXTERNAL a third-party library we do not have it and never will, so
18
+ * it renders as a block and becomes
19
+ * rules about how to build with it
20
+ *
21
+ * **Depth is not a number, it is where the frontier sits.** The skill descends
22
+ * until it meets another component or a library, stops, and records the edge -
23
+ * which answers "how deep should a part go" without inventing a parameter, and
24
+ * makes the answer different per component because it genuinely is.
25
+ *
26
+ * WHAT THIS FILE GUARANTEES, and it is the reason the two halves are derived from
27
+ * one source instead of being sent as two fields: the flat `parts` map and the
28
+ * tree can never disagree. A tree pointing at a part that does not exist would
29
+ * validate everywhere and render as a hole, and "valid and unread" is the worst
30
+ * of the three outcomes this pipeline has paid for twice.
31
+ */
32
+ import { transcribe, } from "./doctor/transcribe.js";
33
+ /** The nine forms, closed. A renderer that accepts any tag draws anything. */
34
+ const FORMS = new Set([
35
+ "image",
36
+ "heading",
37
+ "text",
38
+ "button",
39
+ "field",
40
+ "icon",
41
+ "row",
42
+ "stack",
43
+ "component",
44
+ "external",
45
+ ]);
46
+ /** The two that arrange, and the only two that take children. */
47
+ const CONTAINERS = new Set(["row", "stack"]);
48
+ /** The two frontiers: no name, no styles - what is there is not ours to hold. */
49
+ const FRONTIERS = new Set(["component", "external"]);
50
+ /**
51
+ * Names that ARE the element itself. A node for one of these draws a box inside
52
+ * its own box, because its styles already sit on the component's base.
53
+ */
54
+ const STRUCTURAL = /^(root|wrapper|base|el|outer)$/i;
55
+ /** A pathological transcription of somebody's DOM, bounded. Six levels reaches
56
+ * any real component; past that it is layout divs all the way down. */
57
+ const MAX_DEPTH = 6;
58
+ const MAX_NODES = 48;
59
+ /**
60
+ * `Actions.Generate` → `actions-generate`, and never `actions.generate`.
61
+ *
62
+ * A dot compiles to `.ds-card-actions.generate`, which is two classes and
63
+ * invalid - and `z.record(z.string())` validates the key all the way through to
64
+ * a broken stylesheet, so the guard has to be here rather than in the schema.
65
+ */
66
+ export function safePartName(raw) {
67
+ return raw
68
+ .trim()
69
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/-+/g, "-")
73
+ .replace(/^-|-$/g, "")
74
+ .replace(/^([0-9])/, "n$1");
75
+ }
76
+ /**
77
+ * Which form a node takes, forgiving a wrong label without losing the subtree.
78
+ *
79
+ * A tenth form is a mistake, and the cheap response - drop the node - would take
80
+ * its children with it. Something with children ARRANGES, so it becomes a stack;
81
+ * something without carries content, so it becomes text.
82
+ */
83
+ function formOf(node) {
84
+ const as = String(node.as ?? "")
85
+ .trim()
86
+ .toLowerCase();
87
+ if (FORMS.has(as))
88
+ return { as, coerced: false };
89
+ const arranges = Array.isArray(node.children) && node.children.length > 0;
90
+ return { as: arranges ? "stack" : "text", coerced: true };
91
+ }
92
+ /**
93
+ * Fold the anatomy into a flat parts map and a tree, in one pass.
94
+ *
95
+ * `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
96
+ * to the name they gave it rather than to a literal.
97
+ */
98
+ export function resolveAnatomy(read, declared, deps) {
99
+ const parts = {};
100
+ const composes = [];
101
+ const external = [];
102
+ const notes = [];
103
+ let budget = MAX_NODES;
104
+ let coerced = 0;
105
+ /**
106
+ * A SINGLE STRUCTURAL ROOT IS THE COMPONENT, NOT A NODE OF IT.
107
+ *
108
+ * The skill is told not to send `root`/`wrapper`, and when it does anyway at
109
+ * the top the honest fix is to unwrap it: its styles are the base's, and
110
+ * keeping it would draw the component inside a copy of itself.
111
+ */
112
+ let top = read;
113
+ while (Array.isArray(top) &&
114
+ top.length === 1 &&
115
+ top[0] &&
116
+ STRUCTURAL.test(String(top[0].name ?? "")) &&
117
+ Array.isArray(top[0].children) &&
118
+ top[0].children.length > 0) {
119
+ top = top[0].children;
120
+ notes.push("the outermost wrapper is the component itself, so its children are the anatomy");
121
+ }
122
+ /** Part names, deduped: a second `label` becomes `label-2`, deterministically. */
123
+ const claim = (wanted) => {
124
+ if (parts[wanted] == null)
125
+ return wanted;
126
+ for (let n = 2; n < 100; n++) {
127
+ const candidate = `${wanted}-${n}`;
128
+ if (parts[candidate] == null)
129
+ return candidate;
130
+ }
131
+ return `${wanted}-x`;
132
+ };
133
+ const walk = (nodes, depth) => {
134
+ const out = [];
135
+ if (depth > MAX_DEPTH)
136
+ return out;
137
+ for (const node of nodes) {
138
+ if (!node || typeof node !== "object")
139
+ continue;
140
+ if (budget <= 0)
141
+ break;
142
+ budget -= 1;
143
+ const { as, coerced: wasCoerced } = formOf(node);
144
+ if (wasCoerced)
145
+ coerced += 1;
146
+ // A FRONTIER carries a reference and nothing else. A name or a class list
147
+ // on one would be us claiming styles that belong to somebody else.
148
+ if (FRONTIERS.has(as)) {
149
+ if (as === "component") {
150
+ const ref = String(node.ref ?? "").trim();
151
+ if (!ref)
152
+ continue;
153
+ if (!composes.includes(ref))
154
+ composes.push(ref);
155
+ out.push({ as, ref });
156
+ continue;
157
+ }
158
+ const from = String(node.from ?? "").trim();
159
+ if (!from)
160
+ continue;
161
+ const version = deps?.[from];
162
+ if (!external.some((e) => e.from === from))
163
+ external.push({ from, version });
164
+ out.push(version ? { as, from, version } : { as, from });
165
+ continue;
166
+ }
167
+ const node_ = { as };
168
+ // The NAME is what carries the styles, so it is claimed even when nothing
169
+ // resolved: the tree triggers on presence, and structure is most of the
170
+ // value. A node with no name is pure structure and that is legitimate.
171
+ const wanted = safePartName(String(node.name ?? ""));
172
+ if (wanted) {
173
+ const key = claim(wanted);
174
+ if (key !== wanted) {
175
+ notes.push(`two parts named \`${wanted}\` - the second is \`${key}\``);
176
+ }
177
+ const classes = typeof node.classes === "string"
178
+ ? node.classes.split(/\s+/).filter(Boolean)
179
+ : [];
180
+ const t = transcribe(classes, declared);
181
+ parts[key] = { base: t.base, dark: t.dark, states: t.states };
182
+ node_.part = key;
183
+ }
184
+ const text = typeof node.text === "string" ? node.text.trim() : "";
185
+ if (text)
186
+ node_.text = text.slice(0, 120);
187
+ // Only the two arrangers descend. A leaf with children is a leaf that was
188
+ // labelled wrongly, and its children would render nowhere.
189
+ if (CONTAINERS.has(as) && Array.isArray(node.children)) {
190
+ const children = walk(node.children, depth + 1);
191
+ if (children.length > 0)
192
+ node_.children = children;
193
+ }
194
+ else if (Array.isArray(node.children) && node.children.length > 0) {
195
+ const children = walk(node.children, depth + 1);
196
+ // Hoist rather than lose them: the label was wrong, the structure was not.
197
+ out.push(node_, ...children);
198
+ continue;
199
+ }
200
+ out.push(node_);
201
+ }
202
+ return out;
203
+ };
204
+ const tree = walk(top, 0);
205
+ if (coerced > 0) {
206
+ notes.push(`${coerced} node${coerced === 1 ? "" : "s"} named a form that does not exist - kept, drawn as what ${coerced === 1 ? "it holds" : "they hold"}`);
207
+ }
208
+ if (budget <= 0) {
209
+ notes.push(`the anatomy was longer than ${MAX_NODES} nodes - the rest is layout, and it is not read`);
210
+ }
211
+ return { parts, tree, composes, external, notes };
212
+ }
213
+ /**
214
+ * The OLD flat form, folded into the same result shape.
215
+ *
216
+ * 23 standard components carry flat parts and no tree, and a skill written
217
+ * against the previous contract sends this. Both keep working: the parts style
218
+ * correctly, and the absence of a tree means the preview lays them out in a row
219
+ * exactly as it did before.
220
+ */
221
+ export function resolveFlatParts(read, declared) {
222
+ const parts = {};
223
+ for (const part of read) {
224
+ const name = safePartName(String(part?.name ?? ""));
225
+ if (!name || typeof part.classes !== "string")
226
+ continue;
227
+ const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
228
+ parts[name] = { base: t.base, dark: t.dark, states: t.states };
229
+ }
230
+ return { parts, tree: [], composes: [], external: [], notes: [] };
231
+ }
@@ -6,7 +6,7 @@ import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.
6
6
  import { buildGuide } from "../guide.js";
7
7
  import { body as line, section, snippet } from "../output.js";
8
8
  import { fetchDesignSystem } from "../registry.js";
9
- import { describeFiltered, rulesForProject } from "../rule-filter.js";
9
+ import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.js";
10
10
  import { detectStack } from "../stack.js";
11
11
  async function readRootLock(path) {
12
12
  try {
@@ -97,6 +97,28 @@ export async function add(slug, opts) {
97
97
  // a thing the person should hear once, not discover by its absence.
98
98
  if (leftOut)
99
99
  console.log(line(leftOut));
100
+ /**
101
+ * 5b-ii. THE PACKAGES THIS SYSTEM NEEDS, kept where the doctor can read them.
102
+ *
103
+ * `rules.md` is prose with maximum authority, and prose is the wrong thing to
104
+ * enforce against: a check that grepped "requires @tiptap/react" out of a
105
+ * sentence would break the first time somebody reworded it. So the one
106
+ * verifiable field travels as data, filtered by THIS project's stack exactly
107
+ * like the prose was.
108
+ *
109
+ * Written only when there is something to say, so a system with no third-party
110
+ * component leaves no file behind.
111
+ */
112
+ const required = (payload.ruleSet ?? [])
113
+ .filter((r) => r.requires && ruleApplies(r, stack))
114
+ .map((r) => ({
115
+ requires: r.requires,
116
+ applies: r.applies ?? [],
117
+ ...(r.pinned ? { pinned: r.pinned } : {}),
118
+ }));
119
+ if (required.length > 0) {
120
+ await writeFile(join(slugDir, "requires.json"), `${JSON.stringify(required, null, 2)}\n`, "utf8");
121
+ }
100
122
  // 5c. structured philosophy (personal DS) → philosophy.md at the slug root.
101
123
  // Narrative guidance (mission, principles, voice, motion doctrine…); the
102
124
  // GUIDE points the agent here right after rules.md.
@@ -5,6 +5,7 @@ import { generateComponentFiles } from "../component-codegen.js";
5
5
  import { readProjectConfig, resolveRegistry } from "../config.js";
6
6
  import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
7
7
  import { body, section, snippet } from "../output.js";
8
+ import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
8
9
  import { fetchComponent, RegistryError } from "../registry.js";
9
10
  /**
10
11
  * Writes the shared `cn.ts` next to the components, built from THIS project's
@@ -27,28 +28,6 @@ async function writeCn(root, compDir, slug) {
27
28
  return;
28
29
  await writeFile(join(compDir, "..", "cn.ts"), emitCn(slug, readVocabulary(real)), "utf8");
29
30
  }
30
- /**
31
- * The consumer's React major, or null when we cannot tell.
32
- *
33
- * It decides whether the generated components can take a `ref`: from 19 that is
34
- * an ordinary prop, before it a function component needs `forwardRef`. Guessing
35
- * high on an older project would emit a type that accepts a ref React then
36
- * silently drops, so anything unreadable falls back to the ref-less type.
37
- */
38
- async function reactMajorOf(root) {
39
- const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
40
- if (!raw)
41
- return null;
42
- try {
43
- const pkg = JSON.parse(raw);
44
- const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
45
- const major = /(\d+)/.exec(spec ?? "")?.[1];
46
- return major ? Number(major) : null;
47
- }
48
- catch {
49
- return null;
50
- }
51
- }
52
31
  /** Slugs/names are kebab-case by contract; reject anything else before it ever
53
32
  * reaches a filesystem path (defense-in-depth against `../` traversal). */
54
33
  const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -103,7 +82,16 @@ export async function component(slug, name, opts) {
103
82
  filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
104
83
  }
105
84
  else {
106
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
85
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root),
86
+ /**
87
+ * THE SPELLING, FROM THE VERSION WE JUST FETCHED.
88
+ *
89
+ * The response wins over the document on disk because it belongs to the
90
+ * exact version being written - and the CSS in `res.css` was compiled with
91
+ * it, so the TSX and the stylesheet in the same folder agree by
92
+ * construction. Falling back to disk keeps an older registry working.
93
+ */
94
+ res.classNames ?? (await readInstalledConvention(root, slug)));
107
95
  for (const file of files) {
108
96
  await writeFile(join(compDir, file.filename), file.code, "utf8");
109
97
  }
@@ -3,6 +3,7 @@ import { join, relative, resolve } from "node:path";
3
3
  import { findDivergences } from "../doctor/coherence.js";
4
4
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
5
  import { checkContracts } from "../doctor/contract-check.js";
6
+ import { describeMissing, missingDependencies, summarizeMissing, } from "../doctor/dependencies.js";
6
7
  import { findFrozenBindings } from "../doctor/frozen.js";
7
8
  import { appendEvent, readEvents, summarize } from "../doctor/ledger.js";
8
9
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
@@ -11,6 +12,7 @@ import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
11
12
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
12
13
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
13
14
  import { body, paint, section, snippet } from "../output.js";
15
+ import { resolveDeps } from "../stack.js";
14
16
  /**
15
17
  * `synthesisui doctor` - the check nobody else ships.
16
18
  *
@@ -114,7 +116,12 @@ export async function loadSystem(root) {
114
116
  .map((e) => e.name);
115
117
  }
116
118
  catch {
117
- return { table: EMPTY_TABLE, recipes: new Map(), documents: [] };
119
+ return {
120
+ table: EMPTY_TABLE,
121
+ recipes: new Map(),
122
+ documents: [],
123
+ requires: [],
124
+ };
118
125
  }
119
126
  // Several systems can live side by side; every token is prefixed --ds-, so
120
127
  // reading them all is both correct and what the running app actually sees.
@@ -123,6 +130,7 @@ export async function loadSystem(root) {
123
130
  let adopted = false;
124
131
  const recipes = new Map();
125
132
  const documents = [];
133
+ const requires = [];
126
134
  for (const slug of slugs) {
127
135
  const dir = join(dsDir, slug);
128
136
  const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
@@ -182,11 +190,25 @@ export async function loadSystem(root) {
182
190
  // A document we cannot parse costs the component pass, not the run.
183
191
  }
184
192
  }
193
+ // Written by `add` only when this system has a third-party component, so its
194
+ // absence is the normal case rather than a failure.
195
+ const reqRaw = await readFile(join(dir, "requires.json"), "utf8").catch(() => "");
196
+ if (reqRaw) {
197
+ try {
198
+ const parsed = JSON.parse(reqRaw);
199
+ if (Array.isArray(parsed))
200
+ requires.push(...parsed);
201
+ }
202
+ catch {
203
+ // Unreadable costs this check, not the run.
204
+ }
205
+ }
185
206
  }
186
207
  return {
187
208
  table: buildTable({ css, lock, source: adopted ? "adopted" : "installed" }),
188
209
  recipes,
189
210
  documents,
211
+ requires,
190
212
  };
191
213
  }
192
214
  /**
@@ -870,6 +892,32 @@ export async function doctor(opts) {
870
892
  say(body("complains - the surface just stays put when the scheme"));
871
893
  say(body("moves around it."));
872
894
  }
895
+ /**
896
+ * THE LIBRARIES THE SYSTEM SAYS YOU NEED - the half that makes a rule about a
897
+ * third-party component into governance rather than a sentence.
898
+ *
899
+ * Some of their components are built on somebody else's library. We do not have
900
+ * it, cannot draw it, and said so: the anatomy stops at the frontier and the
901
+ * knowledge moved into a rule carrying the package name as DATA. This is where
902
+ * that gets checked, against the same manifest reader the stack detection uses.
903
+ *
904
+ * It never installs. A dependency has a licence, a bundle cost and a maintainer
905
+ * attached, so the answer is to report and let them decide.
906
+ */
907
+ const missingDeps = missingDependencies(installed.requires, await resolveDeps(root).catch(() => ({})));
908
+ if (missingDeps.length > 0) {
909
+ say(section("This system needs libraries you do not have"));
910
+ const summary = summarizeMissing(missingDeps);
911
+ if (summary)
912
+ say(body(summary));
913
+ say("");
914
+ for (const dep of describeMissing(missingDeps))
915
+ say(body(dep));
916
+ say("");
917
+ say(body("Nothing was installed for you. Install what you actually use:"));
918
+ say("");
919
+ say(` npm i ${missingDeps.map((d) => d.name).join(" ")}`);
920
+ }
873
921
  const frozenAt = new Set(frozen.map((f) => `${f.component}:${f.where.split(" · ").pop()}`));
874
922
  let offSystemCount = 0;
875
923
  let lawKeepingCount = 0;
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
+ import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
5
6
  import { postGenerate, RegistryError } from "../registry.js";
6
7
  /** PascalCase para o hint de import (course-card → CourseCard). */
7
8
  function pascalName(name) {
@@ -70,7 +71,10 @@ export async function generate(description, opts) {
70
71
  const version = await readActiveVersion(root, slug);
71
72
  const compDir = join(root, config.componentsDir, res.name);
72
73
  await mkdir(compDir, { recursive: true });
73
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles);
74
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles, await reactMajorOf(root),
75
+ // Read off the installed document: a generated component lands in the same
76
+ // project as the stylesheet it has to match.
77
+ await readInstalledConvention(root, slug));
74
78
  for (const file of files) {
75
79
  await writeFile(join(compDir, file.filename), file.code, "utf8");
76
80
  }
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
+ import { resolveAnatomy, resolveFlatParts, } from "../anatomy-read.js";
3
4
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
4
5
  import { findBrokenRefs } from "../doctor/broken-refs.js";
5
6
  import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
@@ -11,9 +12,9 @@ import { describeConvention, describeRemainder, detectConventions, } from "../do
11
12
  import { diagnose, scanSource } from "../doctor/scan.js";
12
13
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
13
14
  import { buildTable } from "../doctor/tokens.js";
14
- import { rootClasses, rootTag, transcribe, transcribeParts, } from "../doctor/transcribe.js";
15
+ import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
15
16
  import { body, paint, section } from "../output.js";
16
- import { detectStack } from "../stack.js";
17
+ import { detectStack, resolveDeps } from "../stack.js";
17
18
  import { walk, walkAll } from "./doctor.js";
18
19
  /**
19
20
  * How many distinct values travel, PER KIND.
@@ -896,28 +897,60 @@ function sayReach(c) {
896
897
  }
897
898
  }
898
899
  /**
899
- * Fold the skill's named parts into the transcription, in place.
900
+ * Fold the skill's anatomy into the transcription, in place.
901
+ *
902
+ * A CLEAN SPLIT OF WHO KNOWS WHAT. Only the skill can say that a span holds the
903
+ * title and that the thing in the middle is their own `TextEditor`; only this
904
+ * side can say that `text-ocean-500` is `{color.ocean.500}` and that
905
+ * `@tiptap/react` is pinned at `^2.1.0` in their manifest. The platform then
906
+ * turns declarations into roles, because only it has the two palettes.
900
907
  *
901
908
  * Their declared tokens come from the census itself, so a run that only sends a
902
909
  * file it was handed still resolves a `bg-ocean-500` by their own name.
903
910
  */
904
- function resolveReadParts(census) {
911
+ async function resolveReadParts(census, root) {
905
912
  const read = census.reading?.components;
906
913
  if (!read)
907
914
  return;
908
915
  const declared = new Map(Object.entries(census.declared));
909
- const looks = census.looks ?? (census.looks = {});
916
+ // The manifest is read ONCE for the whole run: the version belongs in a rule's
917
+ // EVIDENCE, never in its text, so the rule does not go stale on an upgrade and
918
+ // the number is not lost either.
919
+ const deps = await resolveDeps(root).catch(() => ({}));
920
+ census.looks ??= {};
921
+ const looks = census.looks;
910
922
  let named = 0;
923
+ let shaped = 0;
924
+ let edges = 0;
925
+ const libs = new Set();
926
+ const notes = new Set();
911
927
  for (const [component, entry] of Object.entries(read)) {
912
- const parts = entry?.parts;
913
- if (!Array.isArray(parts) || parts.length === 0)
928
+ const anatomy = entry?.anatomy;
929
+ const resolved = Array.isArray(anatomy) && anatomy.length > 0
930
+ ? resolveAnatomy(anatomy, declared, deps)
931
+ : Array.isArray(entry?.parts) && entry.parts.length > 0
932
+ ? resolveFlatParts(entry.parts, declared)
933
+ : null;
934
+ if (!resolved)
914
935
  continue;
915
- const resolved = transcribeParts(parts, declared);
916
- if (Object.keys(resolved).length === 0)
936
+ const hasParts = Object.keys(resolved.parts).length > 0;
937
+ if (!hasParts && resolved.tree.length === 0)
917
938
  continue;
918
939
  const look = looks[component];
940
+ const carried = {
941
+ ...(hasParts ? { parts: resolved.parts } : {}),
942
+ ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
943
+ ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
944
+ ...(resolved.external.length > 0 ? { external: resolved.external } : {}),
945
+ };
919
946
  looks[component] = look
920
- ? { ...look, parts: { ...(look.parts ?? {}), ...resolved } }
947
+ ? {
948
+ ...look,
949
+ ...carried,
950
+ ...(hasParts
951
+ ? { parts: { ...(look.parts ?? {}), ...resolved.parts } }
952
+ : {}),
953
+ }
921
954
  : {
922
955
  base: {},
923
956
  dark: {},
@@ -926,14 +959,37 @@ function resolveReadParts(census) {
926
959
  literals: [],
927
960
  fromToken: 0,
928
961
  fromLiteral: 0,
929
- parts: resolved,
962
+ ...carried,
930
963
  };
931
- named += Object.keys(resolved).length;
964
+ named += Object.keys(resolved.parts).length;
965
+ if (resolved.tree.length > 0)
966
+ shaped += 1;
967
+ edges += resolved.composes.length;
968
+ for (const e of resolved.external)
969
+ libs.add(e.from);
970
+ for (const n of resolved.notes)
971
+ notes.add(n);
932
972
  }
933
- if (named > 0) {
973
+ const total = Object.keys(read).length;
974
+ if (named > 0 || shaped > 0) {
934
975
  console.log("");
935
- console.log(body(`${named} part${named === 1 ? "" : "s"} your reading named across ${Object.keys(read).length} component${Object.keys(read).length === 1 ? "" : "s"} - each previews as what it IS rather than as a text box`));
976
+ console.log(body(`${named} part${named === 1 ? "" : "s"} your reading named across ${total} component${total === 1 ? "" : "s"} - each previews as what it IS rather than as a text box`));
977
+ }
978
+ if (shaped > 0) {
979
+ console.log(body(`${shaped} of them arrived with a SHAPE - the parts nested as they nest in your code, so the preview is the component and not a row of labels`));
980
+ }
981
+ // A frontier is worth its own line, because it is the half nobody else has:
982
+ // the edge is a rule an agent can obey, not a picture.
983
+ if (edges > 0) {
984
+ console.log(body(`${edges} place${edges === 1 ? "" : "s"} where one of your components is built out of another - ${edges === 1 ? "it becomes a rule" : "each becomes a rule"} about the two of them, not a guess`));
985
+ }
986
+ if (libs.size > 0) {
987
+ const named_ = [...libs].slice(0, 3).join(", ");
988
+ console.log(body(`${libs.size} librar${libs.size === 1 ? "y" : "ies"} your components need (${named_}) - ${libs.size === 1 ? "it does" : "they do"} not render here, and what to know about ${libs.size === 1 ? "it" : "them"} travels as rules`));
936
989
  }
990
+ // NO SILENT FIX. A correction made quietly is a lie about what they wrote.
991
+ for (const n of notes)
992
+ console.log(body(n));
937
993
  }
938
994
  export async function runImport(opts) {
939
995
  const root = opts.root ?? process.cwd();
@@ -981,7 +1037,7 @@ export async function runImport(opts) {
981
1037
  * Resolved here rather than at measure time because the reading arrives
982
1038
  * AFTER the census was taken - this is the first moment both halves exist.
983
1039
  */
984
- resolveReadParts(census);
1040
+ await resolveReadParts(census, root);
985
1041
  }
986
1042
  else {
987
1043
  console.log(section("Reading your project"));
@@ -3,6 +3,7 @@ import { basename, join } from "node:path";
3
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { body, section, snippet } from "../output.js";
6
+ import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
6
7
  import { fetchComponent, postRefit, postSaveComponent, RegistryError, } from "../registry.js";
7
8
  /** Slugs INSTALLED under `_synthesisui/ds/` (a `.lock` marks a real install -
8
9
  * a folder holding only refit artifacts doesn't count). */
@@ -132,7 +133,7 @@ export async function refit(file, opts) {
132
133
  if (config.target === "next") {
133
134
  const compDir = join(root, config.componentsDir, res.name);
134
135
  await mkdir(compDir, { recursive: true });
135
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, saved.version, config.styles);
136
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, saved.version, config.styles, await reactMajorOf(root), await readInstalledConvention(root, slug));
136
137
  for (const f of files) {
137
138
  await writeFile(join(compDir, f.filename), f.code, "utf8");
138
139
  }
@@ -4,6 +4,7 @@ import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { diffLocalDocuments, localChangelogMarkdown, } from "../document-diff.js";
6
6
  import { body, section, snippet } from "../output.js";
7
+ import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
7
8
  import { fetchChangelog, fetchComponent, fetchDesignSystem, RegistryError, } from "../registry.js";
8
9
  import { add } from "./add.js";
9
10
  /**
@@ -151,7 +152,11 @@ export async function upgrade(slug, opts) {
151
152
  continue;
152
153
  try {
153
154
  const res = await fetchComponent(base, slug, entry);
154
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
155
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles,
156
+ // Both were missing here, and `upgrade` is the command that REWRITES
157
+ // components somebody already has: without the convention it would have
158
+ // taken a working component and stripped its styles.
159
+ await reactMajorOf(root), res.classNames ?? (await readInstalledConvention(root, slug)));
155
160
  for (const file of files) {
156
161
  await writeFile(join(componentsRoot, entry, file.filename), file.code, "utf8");
157
162
  }