synthesisui 0.16.8 → 0.16.10

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.
@@ -76,7 +76,7 @@ export async function* walkAll(roots) {
76
76
  * and the recipes `add` put next to them in `design-system.json`. Those
77
77
  * recipes are why the component pass can exist at all - a linter has no idea
78
78
  * what `ds-button` promised. */
79
- async function loadSystem(root) {
79
+ export async function loadSystem(root) {
80
80
  const dsDir = join(root, "_synthesisui", "ds");
81
81
  let slugs;
82
82
  try {
@@ -0,0 +1,250 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { relative, resolve } from "node:path";
3
+ import { diagnose, scanSource } from "../doctor/scan.js";
4
+ import { nearestToken, tokenFor } from "../doctor/tokens.js";
5
+ import { component } from "./component.js";
6
+ import { loadSystem, walkAll } from "./doctor.js";
7
+ const send = (msg) => {
8
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
9
+ };
10
+ /** A tool result is always text: readable by the model, and by a person
11
+ * watching the transcript when something goes wrong. */
12
+ const text = (body, isError = false) => ({
13
+ content: [{ type: "text", text: body }],
14
+ ...(isError ? { isError: true } : null),
15
+ });
16
+ /**
17
+ * Everything a command prints, captured.
18
+ *
19
+ * `component()` announces what it fetched, and that line would land in the
20
+ * middle of a JSON-RPC frame. Capturing is not just protection: the output is
21
+ * exactly what the agent needs to read back, so it becomes the result.
22
+ */
23
+ async function capturing(run) {
24
+ const lines = [];
25
+ const real = console.log;
26
+ console.log = (...args) => void lines.push(args.join(" "));
27
+ try {
28
+ const value = await run();
29
+ return { value, out: lines.join("\n") };
30
+ }
31
+ finally {
32
+ console.log = real;
33
+ }
34
+ }
35
+ // ── The tools ────────────────────────────────────────────────────────────────
36
+ const TOOLS = [
37
+ {
38
+ name: "check_file",
39
+ description: "Check one file (or folder) against the installed design system. Returns token coverage, every hardcoded value with the token this project already has for it, and any --ds- name the system does not declare. Run this after writing or editing any UI file, before moving on.",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ path: {
44
+ type: "string",
45
+ description: "File or folder, relative to the project root.",
46
+ },
47
+ },
48
+ required: ["path"],
49
+ },
50
+ },
51
+ {
52
+ name: "find_token",
53
+ description: "Ask what this design system calls a value. Give it a colour, spacing or radius exactly as you would write it (#2563eb, 12px, 0.75rem) and it answers with the token name, or says no token holds it - in which case do NOT invent one.",
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: {
57
+ value: { type: "string", description: "e.g. #2563eb, 12px, 1rem" },
58
+ },
59
+ required: ["value"],
60
+ },
61
+ },
62
+ {
63
+ name: "list_components",
64
+ description: "The components this design system defines, with what each is for. Look here BEFORE writing any UI element from scratch - if something covers the purpose, materialize it with add_component instead.",
65
+ inputSchema: { type: "object", properties: {} },
66
+ },
67
+ {
68
+ name: "add_component",
69
+ description: "Materialize a component from the design system as real typed code in this project, ready to import and extend. Use it yourself - the person who asked for a feature should never have to know component names.",
70
+ inputSchema: {
71
+ type: "object",
72
+ properties: {
73
+ name: {
74
+ type: "string",
75
+ description: "Component name from list_components.",
76
+ },
77
+ },
78
+ required: ["name"],
79
+ },
80
+ },
81
+ ];
82
+ async function checkFile(root, path) {
83
+ const { table } = await loadSystem(root);
84
+ if (table.byName.size === 0)
85
+ return "No design system installed here, so there is nothing to check against. Run `synthesisui init` or `synthesisui adopt`.";
86
+ const scope = resolve(root, path);
87
+ const reports = [];
88
+ for await (const file of walkAll([scope])) {
89
+ const src = await readFile(file, "utf8").catch(() => "");
90
+ if (src)
91
+ reports.push(scanSource(relative(root, file), src, table));
92
+ }
93
+ if (reports.length === 0)
94
+ return `Nothing readable at ${path}.`;
95
+ const d = diagnose(reports);
96
+ const out = [
97
+ `${table.name ?? table.slug}: ${d.coverage}% of design values come from the system.`,
98
+ `${d.tokenUses} from the system, ${d.findings.length} written by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}.`,
99
+ ];
100
+ if (d.findings.length > 0) {
101
+ out.push("", "Written by hand:");
102
+ for (const f of d.findings.slice(0, 40)) {
103
+ out.push(f.token
104
+ ? ` ${f.file}:${f.line} ${f.literal} - this system calls it ${f.token}`
105
+ : ` ${f.file}:${f.line} ${f.literal} - no token holds this value`);
106
+ }
107
+ out.push("", "Replace the ones that have a token. For a value with none, do NOT invent a token: say which value it is and what you would call it, and let a person decide.");
108
+ }
109
+ const phantoms = d.files.flatMap((f) => (f.phantoms ?? []).map((p) => ` ${f.file}:${p.line} ${p.name}`));
110
+ if (phantoms.length > 0) {
111
+ out.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms);
112
+ }
113
+ if (d.findings.length === 0 && phantoms.length === 0)
114
+ out.push("", "Nothing to fix here.");
115
+ return out.join("\n");
116
+ }
117
+ async function findToken(root, value) {
118
+ const { table } = await loadSystem(root);
119
+ if (table.byName.size === 0)
120
+ return "No design system installed here.";
121
+ const exact = tokenFor(table, value);
122
+ if (exact)
123
+ return `${value} is ${exact} in this system. Use var(${exact}).`;
124
+ const near = nearestToken(table, value);
125
+ if (near)
126
+ return `No token holds ${value}. The closest is ${near.name} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`;
127
+ // Same words the managed block uses. A refusal is only useful if it is the
128
+ // same refusal every time.
129
+ return `No token in this system holds ${value}, and nothing is close. Do NOT invent one. Say which value you need and what you would call it, and let a person decide.`;
130
+ }
131
+ async function listComponents(root) {
132
+ const { documents } = await loadSystem(root);
133
+ const rows = [];
134
+ for (const doc of documents) {
135
+ const comps = doc.components;
136
+ for (const [name, recipe] of Object.entries(comps ?? {}))
137
+ rows.push(` ${name}${recipe?.description ? ` - ${recipe.description}` : ""}`);
138
+ }
139
+ if (rows.length === 0)
140
+ return "This system defines no components yet - write what you need with its tokens.";
141
+ return [
142
+ `${rows.length} components. Materialize with add_component before writing one from scratch:`,
143
+ "",
144
+ ...rows.sort(),
145
+ ].join("\n");
146
+ }
147
+ async function addComponent(root, name) {
148
+ const { table } = await loadSystem(root);
149
+ if (!table.slug)
150
+ return "No design system installed here.";
151
+ const { out } = await capturing(() => component(table.slug, name, { dir: root }));
152
+ return `${out}\n\nIt is real code in this project now - import it and extend it rather than writing your own.`;
153
+ }
154
+ // ── The protocol ─────────────────────────────────────────────────────────────
155
+ async function callTool(root, name, args) {
156
+ switch (name) {
157
+ case "check_file":
158
+ return text(await checkFile(root, String(args.path ?? ".")));
159
+ case "find_token":
160
+ return text(await findToken(root, String(args.value ?? "")));
161
+ case "list_components":
162
+ return text(await listComponents(root));
163
+ case "add_component":
164
+ return text(await addComponent(root, String(args.name ?? "")));
165
+ default:
166
+ return text(`No tool named ${name}.`, true);
167
+ }
168
+ }
169
+ /**
170
+ * One request in, one response out - or null for a notification, which MUST
171
+ * NOT be answered. Replying to `notifications/initialized` is the classic way
172
+ * to break a handshake, and it is the kind of thing only a test catches.
173
+ *
174
+ * Split out from the stdin loop so the protocol can be exercised without a
175
+ * pipe: the loop below is framing, this is the protocol.
176
+ */
177
+ export async function handleRequest(root, req) {
178
+ const id = req.id;
179
+ if (id === undefined)
180
+ return null;
181
+ try {
182
+ switch (req.method) {
183
+ case "initialize":
184
+ return {
185
+ jsonrpc: "2.0",
186
+ id,
187
+ result: {
188
+ // Echo the client's version rather than asserting our own: this
189
+ // server speaks a subset every revision of the protocol has had,
190
+ // and refusing a client over a date string helps nobody.
191
+ protocolVersion: req.params?.protocolVersion ?? "2025-06-18",
192
+ capabilities: { tools: {} },
193
+ serverInfo: { name: "synthesisui", version: VERSION },
194
+ },
195
+ };
196
+ case "ping":
197
+ return { jsonrpc: "2.0", id, result: {} };
198
+ case "tools/list":
199
+ return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
200
+ case "tools/call": {
201
+ const name = String(req.params?.name ?? "");
202
+ const args = (req.params?.arguments ?? {});
203
+ return { jsonrpc: "2.0", id, result: await callTool(root, name, args) };
204
+ }
205
+ default:
206
+ return {
207
+ jsonrpc: "2.0",
208
+ id,
209
+ error: { code: -32601, message: `Method not found: ${req.method}` },
210
+ };
211
+ }
212
+ }
213
+ catch (err) {
214
+ // A tool that throws must not take the session with it.
215
+ return {
216
+ jsonrpc: "2.0",
217
+ id,
218
+ result: text(err instanceof Error ? err.message : String(err), true),
219
+ };
220
+ }
221
+ }
222
+ export async function mcp(opts) {
223
+ const root = resolve(opts.dir ?? process.cwd());
224
+ let buffer = "";
225
+ process.stdin.setEncoding("utf8");
226
+ for await (const chunk of process.stdin) {
227
+ buffer += chunk;
228
+ // Newline-delimited JSON. A frame can arrive split across chunks, and two
229
+ // can arrive in one.
230
+ let nl = buffer.indexOf("\n");
231
+ while (nl !== -1) {
232
+ const line = buffer.slice(0, nl).trim();
233
+ buffer = buffer.slice(nl + 1);
234
+ nl = buffer.indexOf("\n");
235
+ if (!line)
236
+ continue;
237
+ let req;
238
+ try {
239
+ req = JSON.parse(line);
240
+ }
241
+ catch {
242
+ continue; // Unparseable frame with no id: nobody to answer.
243
+ }
244
+ const res = await handleRequest(root, req);
245
+ if (res)
246
+ send(res);
247
+ }
248
+ }
249
+ }
250
+ const VERSION = "0.16.9";
@@ -180,6 +180,57 @@ export function parseTokens(css) {
180
180
  * Harmless while nothing asked about existence. The moment the phantom check
181
181
  * did, it called them invented (caught before shipping, 27/07).
182
182
  */
183
+ /**
184
+ * AN ALIAS IS NOT A DEAD END. IT IS USUALLY THE ANSWER.
185
+ *
186
+ * Every real system is written in two layers - `--ds-color-blue-600: #2563eb`
187
+ * on the shelf, `--ds-color-semantic-info: var(--ds-color-blue-600)` for the
188
+ * intent - and the value table only ever held the first. So `tokenFor` answered
189
+ * `#2563eb → --ds-color-blue-600`, the shelf, and its own rule two lines below
190
+ * ("semantic roles name intent; prefer intent") could never fire, because no
191
+ * semantic name was in the table to prefer.
192
+ *
193
+ * That is the one sentence the product is sold on: not "you have a hardcoded
194
+ * colour" but "your system already calls it this". It was naming the wrong
195
+ * thing on every system that layers its tokens, which is all of them. Found
196
+ * when an agent using the MCP tools read the answer, checked it against
197
+ * CLAUDE.md, and corrected the tool (my-test4, 27/07).
198
+ *
199
+ * Following the chain costs one pass and needs no CSS engine: a token either
200
+ * holds a literal or points at exactly one other token.
201
+ */
202
+ export function resolveAliases(raw) {
203
+ const out = new Map();
204
+ const literal = (name, depth) => {
205
+ // A cycle is malformed CSS, not something to crash on. Ten hops is far
206
+ // past any real system and stops the recursion dead.
207
+ if (depth > 10)
208
+ return null;
209
+ const value = raw.get(name);
210
+ if (value === undefined)
211
+ return null;
212
+ const alias = /^var\(\s*(--[a-z0-9_-]+)/i.exec(value.trim());
213
+ return alias ? literal(alias[1].toLowerCase(), depth + 1) : value;
214
+ };
215
+ for (const name of raw.keys()) {
216
+ const value = literal(name, 0);
217
+ if (value !== null)
218
+ out.set(name, value);
219
+ }
220
+ return out;
221
+ }
222
+ /** Every `--ds-*` declaration, aliases kept - the input `resolveAliases` needs.
223
+ * `parseTokens` drops them, which is right for a table of literals and wrong
224
+ * for a table of what things are called. */
225
+ export function parseTokensWithAliases(css) {
226
+ const out = new Map();
227
+ for (const m of css.matchAll(/(--ds-[a-z0-9-]+)\s*:\s*([^;}]+)[;}]/gi)) {
228
+ const name = m[1].toLowerCase();
229
+ if (!out.has(name))
230
+ out.set(name, m[2].trim());
231
+ }
232
+ return out;
233
+ }
183
234
  export function parseDeclaredNames(css) {
184
235
  const out = new Set();
185
236
  for (const m of css.matchAll(/(--[a-z0-9_-]+)\s*:\s*[^;}]+[;}]/gi))
@@ -280,14 +331,35 @@ export function buildTable(input) {
280
331
  const byName = source === "installed"
281
332
  ? parseTokens(input.css)
282
333
  : parseRootTokens(input.css);
334
+ /**
335
+ * Built from the RESOLVED map, not from `byName`. A semantic role that
336
+ * aliases a primitive has to be findable by the primitive's value, or the
337
+ * "prefer intent" rule below is unreachable.
338
+ */
339
+ const resolved = source === "installed"
340
+ ? resolveAliases(parseTokensWithAliases(input.css))
341
+ : byName;
342
+ /**
343
+ * Aliases first in every list.
344
+ *
345
+ * `tokenFor` preferred a name containing `-semantic-`, which covers colour
346
+ * and nothing else: `--ds-spacing-md: var(--ds-spacing-4)` has no such
347
+ * marker, so the answer came back as the raw step rather than the scale name
348
+ * anyone actually writes. The general rule is structural, not lexical - in a
349
+ * layered system the name that POINTS at another name is the layer above.
350
+ */
351
+ const raw = source === "installed" ? parseTokensWithAliases(input.css) : new Map();
352
+ const isAlias = (name) => /^var\(/.test((raw.get(name) ?? "").trim());
283
353
  const byValue = new Map();
284
- for (const [name, value] of byName) {
354
+ for (const [name, value] of resolved) {
285
355
  const key = normalizeValue(value);
286
356
  const list = byValue.get(key);
287
- if (list)
288
- list.push(name);
289
- else
357
+ if (!list)
290
358
  byValue.set(key, [name]);
359
+ else if (isAlias(name))
360
+ list.unshift(name);
361
+ else
362
+ list.push(name);
291
363
  }
292
364
  return {
293
365
  source: byName.size > 0 ? source : null,
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { generate } from "./commands/generate.js";
9
9
  import { init } from "./commands/init.js";
10
10
  import { list } from "./commands/list.js";
11
11
  import { login } from "./commands/login.js";
12
+ import { mcp } from "./commands/mcp.js";
12
13
  import { refit } from "./commands/refit.js";
13
14
  import { template } from "./commands/template.js";
14
15
  import { upgrade } from "./commands/upgrade.js";
@@ -133,6 +134,11 @@ async function main() {
133
134
  slug: typeof flags.slug === "string" ? flags.slug : undefined,
134
135
  });
135
136
  break;
137
+ // Long-lived: it owns stdin/stdout until the client closes the pipe, so
138
+ // it must not be reached by anything that prints.
139
+ case "mcp":
140
+ await mcp({ dir });
141
+ return;
136
142
  case "doctor":
137
143
  // positional paths scope the READING (the system is still found from the
138
144
  // root): `doctor apps/web packages/ui` in a monorepo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.8",
3
+ "version": "0.16.10",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {