synthesisui 0.4.13 → 0.6.0

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/README.md CHANGED
@@ -38,6 +38,69 @@ your own in two minutes.
38
38
  | `refit <file>` | Send an app component back into your design system |
39
39
  | `upgrade <slug>` | Diff your `.lock` against the latest version and migrate |
40
40
  | `clean` | Remove materialized files and the managed CLAUDE.md block |
41
+ | `doctor` | Audit the repo for drift: every design value written by hand, and the token your system already has for it |
42
+
43
+ ## `doctor`
44
+
45
+ Every tool in this space promises output that is brand consistent and free of
46
+ drift. None of them checks. This checks, in the only place it can be true -
47
+ the code that shipped.
48
+
49
+ ```
50
+ npx synthesisui@latest doctor
51
+ ```
52
+
53
+ ```
54
+ ── Doctor ────────────────────────────────────────────────────────
55
+
56
+ Aluna v1 - 91 tokens, 53 files read
57
+
58
+ Token coverage ████████████████████████ 98%
59
+ 2019 from the system, 34 by hand
60
+
61
+ ── Drift ─────────────────────────────────────────────────────────
62
+
63
+ 26 colour
64
+ 8 spacing
65
+
66
+ app/welcome2/_components/signature.tsx
67
+ 19 #8b8bf1 → --ds-color-blue-500
68
+ 21 #f1f3fa → --ds-color-gray-100
69
+ ```
70
+
71
+ ### Overruled
72
+
73
+ With a system installed it runs a second pass that nothing else can:
74
+
75
+ ```
76
+ ── Overruled ─────────────────────────────────────────────────────
77
+
78
+ 3 places where the code takes a component
79
+ the system defines, and then overrules it locally.
80
+
81
+ components/Hero.tsx
82
+ 12 ds-button · border-radius: 4
83
+ the recipe binds {radius.md}
84
+ 18 ds-button · padding: px-8
85
+ the recipe binds {spacing.2xs} {spacing.md}
86
+ ```
87
+
88
+ It only flags a property the recipe **actually binds**. `w-full` beside a
89
+ button is layout; `px-8` is drift, because the recipe already decided the
90
+ padding. A rule reading `var(--ds-…)` back is the opposite of overruling and
91
+ is left alone. This needs the recipes, which are only in the project because
92
+ `add` put them there - a linter has no idea what `ds-button` promised.
93
+
94
+ The last column is the point: not "you hardcoded a colour", but **the name
95
+ your own system already has for it**. Exact matches only - a tool that guesses
96
+ a near colour invites a silent visual change, and a diagnosis nobody trusts is
97
+ worse than none.
98
+
99
+ With no system installed it still finds every hand-written value and counts
100
+ the distinct ones. Runs offline, needs no account, writes nothing.
101
+
102
+ `--strict` exits 1 when drift is found, for CI. `--all` lists everything
103
+ instead of the loudest files.
41
104
 
42
105
  ## What `add` materializes
43
106
 
@@ -0,0 +1,280 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { bindingsFromDocument, findOverrides, } from "../doctor/overrides.js";
4
+ import { diagnose, scanSource, } from "../doctor/scan.js";
5
+ import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
6
+ import { body, section, snippet } from "../output.js";
7
+ /**
8
+ * `synthesisui doctor` - the check nobody else ships.
9
+ *
10
+ * Every generator in this market now promises "brand consistent, no drift".
11
+ * Not one of them verifies it. This does, in the only place it can be true:
12
+ * the code that actually shipped.
13
+ *
14
+ * Two audiences, one command:
15
+ *
16
+ * - With a system installed, it does the thing that cannot be faked - it names
17
+ * the project's OWN token for the value someone hardcoded. "#2563eb, which
18
+ * your system calls --ds-color-semantic-info." Nothing generic; the
19
+ * diagnosis is in their vocabulary.
20
+ * - With no system installed, it still finds every hand-written design value
21
+ * and counts the distinct ones. That number IS the pitch, and it costs the
22
+ * reader nothing to get.
23
+ *
24
+ * Runs offline, needs no account, and touches nothing. A tool that asks for a
25
+ * signup before it tells you anything is a tool nobody runs twice.
26
+ */
27
+ const EXTS = [".tsx", ".ts", ".jsx", ".js", ".css", ".scss", ".vue", ".svelte"];
28
+ const SKIP = new Set([
29
+ "node_modules",
30
+ ".next",
31
+ ".git",
32
+ "dist",
33
+ "build",
34
+ "out",
35
+ "coverage",
36
+ ".turbo",
37
+ ".vercel",
38
+ // Our own installed artifacts are the answer, not the problem.
39
+ "_synthesisui",
40
+ ]);
41
+ async function* walk(dir) {
42
+ // `readdir`'s overloads infer a Buffer-named Dirent without an explicit
43
+ // encoding; naming it keeps `e.name` a string.
44
+ const entries = await readdir(dir, {
45
+ withFileTypes: true,
46
+ encoding: "utf8",
47
+ }).catch(() => []);
48
+ for (const e of entries) {
49
+ if (e.name.startsWith(".") && e.name !== ".")
50
+ continue;
51
+ const full = join(dir, e.name);
52
+ if (e.isDirectory()) {
53
+ if (SKIP.has(e.name))
54
+ continue;
55
+ yield* walk(full);
56
+ }
57
+ else if (EXTS.some((x) => e.name.endsWith(x))) {
58
+ yield full;
59
+ }
60
+ }
61
+ }
62
+ /** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`,
63
+ * and the recipes `add` put next to them in `design-system.json`. Those
64
+ * recipes are why the component pass can exist at all - a linter has no idea
65
+ * what `ds-button` promised. */
66
+ async function loadSystem(root) {
67
+ const dsDir = join(root, "_synthesisui", "ds");
68
+ let slugs;
69
+ try {
70
+ slugs = (await readdir(dsDir, { withFileTypes: true }))
71
+ .filter((e) => e.isDirectory())
72
+ .map((e) => e.name);
73
+ }
74
+ catch {
75
+ return { table: EMPTY_TABLE, recipes: new Map() };
76
+ }
77
+ // Several systems can live side by side; every token is prefixed --ds-, so
78
+ // reading them all is both correct and what the running app actually sees.
79
+ let css = "";
80
+ let lock = null;
81
+ const recipes = new Map();
82
+ for (const slug of slugs) {
83
+ const dir = join(dsDir, slug);
84
+ const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
85
+ let mine = null;
86
+ try {
87
+ mine = raw ? JSON.parse(raw) : null;
88
+ }
89
+ catch {
90
+ mine = null;
91
+ }
92
+ lock ??= mine;
93
+ // The file at the root is a POINTER - `@import "./v1/tokens.css"` - so the
94
+ // pinned folder is where the declarations actually live. Read that first,
95
+ // and fall back to following whatever the root imports, for a project that
96
+ // pinned by hand.
97
+ const root = await readFile(join(dir, "tokens.css"), "utf8").catch(() => "");
98
+ let real = mine?.version
99
+ ? await readFile(join(dir, `v${mine.version}`, "tokens.css"), "utf8").catch(() => "")
100
+ : "";
101
+ if (!real) {
102
+ for (const m of root.matchAll(/@import\s+["']([^"']+)["']/g)) {
103
+ real += `\n${await readFile(join(dir, m[1]), "utf8").catch(() => "")}`;
104
+ }
105
+ }
106
+ css += `\n${real || root}`;
107
+ const docRaw = mine?.version
108
+ ? await readFile(join(dir, `v${mine.version}`, "design-system.json"), "utf8").catch(() => "")
109
+ : "";
110
+ if (docRaw) {
111
+ try {
112
+ for (const [k, v] of bindingsFromDocument(JSON.parse(docRaw))) {
113
+ if (!recipes.has(k))
114
+ recipes.set(k, v);
115
+ }
116
+ }
117
+ catch {
118
+ // A document we cannot parse costs the component pass, not the run.
119
+ }
120
+ }
121
+ }
122
+ return { table: buildTable({ css, lock }), recipes };
123
+ }
124
+ const KIND_LABEL = {
125
+ color: "colour",
126
+ radius: "radius",
127
+ spacing: "spacing",
128
+ font: "type",
129
+ };
130
+ /** A bar you can read at a glance, in a font that is always monospace. */
131
+ function meter(pct, width = 24) {
132
+ const filled = Math.round((pct / 100) * width);
133
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
134
+ }
135
+ function verdict(d, hasSystem) {
136
+ // Nothing found and nothing installed: a utility package, a config folder,
137
+ // the wrong directory. Selling a design system here would be noise.
138
+ if (!hasSystem && d.findings.length === 0) {
139
+ return [
140
+ body("No design values are written by hand here, and no system is"),
141
+ body("installed. Nothing to fix, and nothing to compare against."),
142
+ ];
143
+ }
144
+ if (!hasSystem) {
145
+ const distinct = new Set(d.findings.map((f) => f.literal.toLowerCase()));
146
+ return [
147
+ body(`${distinct.size} distinct design values are written by hand here.`),
148
+ body("No design system is installed, so none of them has a name yet."),
149
+ "",
150
+ body("Give them one:"),
151
+ snippet(["npx synthesisui@latest init --ds <slug>"]),
152
+ body("Browse systems at https://www.synthesisui.com/gallery"),
153
+ ];
154
+ }
155
+ if (d.findings.length === 0) {
156
+ return [
157
+ body("No drift. Every design value in this project comes from the"),
158
+ body("system. That is a rarer sentence than it sounds."),
159
+ ];
160
+ }
161
+ const lines = [
162
+ body(`${d.named} of ${d.findings.length} already have a name in your system.`),
163
+ body("Those are the cheap ones: swap the literal for the token."),
164
+ ];
165
+ if (d.findings.length > d.named) {
166
+ lines.push("", body(`The other ${d.findings.length - d.named} are decisions your system has not made yet.`), body("Either they belong in it, or they should not be in the code."));
167
+ }
168
+ return lines;
169
+ }
170
+ export async function doctor(opts) {
171
+ const root = resolve(opts.dir ?? process.cwd());
172
+ const { table, recipes } = await loadSystem(root);
173
+ const hasSystem = table.byName.size > 0;
174
+ const reports = [];
175
+ const overrides = [];
176
+ for await (const file of walk(root)) {
177
+ const src = await readFile(file, "utf8").catch(() => "");
178
+ if (!src)
179
+ continue;
180
+ const rel = relative(root, file);
181
+ reports.push(scanSource(rel, src, table));
182
+ if (recipes.size > 0) {
183
+ for (const o of findOverrides(src, recipes))
184
+ overrides.push({ ...o, file: rel });
185
+ }
186
+ }
187
+ if (reports.length === 0) {
188
+ console.log(`\nNothing to read in ${root}.\n`);
189
+ return;
190
+ }
191
+ const d = diagnose(reports);
192
+ console.log(section("Doctor"));
193
+ console.log(body(hasSystem
194
+ ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
195
+ : `No system installed - ${d.scanned} files read`));
196
+ if (hasSystem) {
197
+ console.log("");
198
+ console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
199
+ console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
200
+ }
201
+ if (d.findings.length > 0) {
202
+ console.log(section("Drift"));
203
+ const order = ["color", "radius", "spacing", "font"].filter((k) => d.counts[k] > 0);
204
+ for (const kind of order) {
205
+ console.log(body(`${d.counts[kind]} ${KIND_LABEL[kind]}`));
206
+ }
207
+ // What a person actually acts on first: the value repeated everywhere.
208
+ // One decision here retires dozens of sites, and a list sorted by file
209
+ // never tells you that.
210
+ const repeats = d.repeats.slice(0, 5);
211
+ if (repeats.length > 0) {
212
+ console.log("");
213
+ console.log(body("Most repeated"));
214
+ const w = Math.max(...repeats.map((r) => r.literal.length));
215
+ for (const r of repeats) {
216
+ const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
217
+ const named = r.token ? ` → ${r.token}` : "";
218
+ console.log(` ${r.literal.padEnd(w)} ${where}${named}`);
219
+ }
220
+ }
221
+ // Loudest files first: drift concentrates, and the fix is usually one
222
+ // shared component rather than two hundred call sites.
223
+ const files = [...d.files].sort((a, b) => b.findings.length - a.findings.length);
224
+ const shownFiles = opts.all ? files : files.slice(0, 8);
225
+ console.log("");
226
+ for (const f of shownFiles) {
227
+ console.log(body(`${f.file}`));
228
+ const shown = opts.all ? f.findings : f.findings.slice(0, 3);
229
+ for (const x of shown) {
230
+ const named = x.token
231
+ ? `→ ${x.token}`
232
+ : "→ no token holds this value yet";
233
+ console.log(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
234
+ }
235
+ if (f.findings.length > shown.length) {
236
+ console.log(` +${f.findings.length - shown.length} more`);
237
+ }
238
+ console.log("");
239
+ }
240
+ if (files.length > shownFiles.length) {
241
+ console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
242
+ }
243
+ }
244
+ if (overrides.length > 0) {
245
+ console.log(section("Overruled"));
246
+ console.log(body(`${overrides.length} place${overrides.length === 1 ? "" : "s"} where the code takes a component`));
247
+ console.log(body("the system defines, and then overrules it locally."));
248
+ console.log("");
249
+ const byFile = new Map();
250
+ for (const o of overrides) {
251
+ const list = byFile.get(o.file);
252
+ if (list)
253
+ list.push(o);
254
+ else
255
+ byFile.set(o.file, [o]);
256
+ }
257
+ const shown = opts.all ? [...byFile] : [...byFile].slice(0, 6);
258
+ for (const [file, list] of shown) {
259
+ console.log(body(file));
260
+ for (const o of opts.all ? list : list.slice(0, 3)) {
261
+ console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
262
+ if (o.recipe)
263
+ console.log(` the recipe binds ${o.recipe}`);
264
+ }
265
+ if (!opts.all && list.length > 3) {
266
+ console.log(` +${list.length - 3} more`);
267
+ }
268
+ console.log("");
269
+ }
270
+ if (byFile.size > shown.length) {
271
+ console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
272
+ }
273
+ }
274
+ console.log(section("What this means"));
275
+ for (const line of verdict(d, hasSystem))
276
+ console.log(line);
277
+ console.log("");
278
+ if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
279
+ process.exitCode = 1;
280
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * DOCTOR · drift ON a component the project is already using.
3
+ *
4
+ * The generic pass finds a hardcoded value anywhere. This finds the sharper
5
+ * thing: a place where the code takes a component the system defines and then
6
+ * overrules the system locally.
7
+ *
8
+ * <button className="ds-button" style={{ borderRadius: 4 }}>
9
+ * → the recipe already binds border-radius to {radius.md}
10
+ *
11
+ * This is the check nothing else can run, and the reason is structural: it
12
+ * needs the recipes, and the recipes are only in the project because `add`
13
+ * put them there (`_synthesisui/ds/<slug>/v<n>/design-system.json`). A linter
14
+ * has no idea what `ds-button` promised.
15
+ *
16
+ * It only ever flags a property the recipe ACTUALLY binds. `w-full` next to a
17
+ * button is layout, not drift; `p-4` is drift, because the recipe already
18
+ * decided the padding.
19
+ */
20
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
21
+ /**
22
+ * Every property a recipe touches - base, variants, states and parts. A
23
+ * variant binding counts: if `intent=primary` sets the background, then
24
+ * writing a background by hand overrules the system just as surely.
25
+ */
26
+ export function bindingsOf(recipe) {
27
+ const props = new Set();
28
+ const baseValues = new Map();
29
+ const collect = (block, isBase) => {
30
+ if (!block || typeof block !== "object")
31
+ return;
32
+ for (const [k, v] of Object.entries(block)) {
33
+ if (typeof v === "string" || typeof v === "number") {
34
+ const p = kebab(k);
35
+ props.add(p);
36
+ if (isBase)
37
+ baseValues.set(p, String(v));
38
+ }
39
+ else if (v && typeof v === "object") {
40
+ collect(v, false);
41
+ }
42
+ }
43
+ };
44
+ const r = (recipe ?? {});
45
+ collect(r.base, true);
46
+ collect(r.variants, false);
47
+ collect(r.states, false);
48
+ collect(r.parts, false);
49
+ return { props, baseValues };
50
+ }
51
+ /**
52
+ * Tailwind utilities that plainly set a CSS property. Deliberately short:
53
+ * every entry here can produce an accusation, so anything ambiguous is left
54
+ * out. Being quiet and right beats being loud.
55
+ */
56
+ const UTILITY = [
57
+ [
58
+ /^rounded(-(?:sm|md|lg|xl|2xl|3xl|full|none))?$|^rounded-\[/,
59
+ "border-radius",
60
+ ],
61
+ [/^p-|^px-|^py-|^pt-|^pr-|^pb-|^pl-/, "padding"],
62
+ [/^gap-/, "gap"],
63
+ [/^bg-\[|^bg-(?!clip|blend|origin|repeat|fixed|local|scroll)/, "background"],
64
+ [/^shadow(-|$)/, "box-shadow"],
65
+ [
66
+ /^font-(?:thin|light|normal|medium|semibold|bold|extrabold|black)$/,
67
+ "font-weight",
68
+ ],
69
+ [/^(?:text|font)-\[/, "font-size"],
70
+ [/^tracking-/, "letter-spacing"],
71
+ [/^border-\[|^border-\d/, "border-width"],
72
+ ];
73
+ function utilityProp(cls) {
74
+ for (const [re, prop] of UTILITY)
75
+ if (re.test(cls))
76
+ return prop;
77
+ return null;
78
+ }
79
+ const DS_IN_CLASS = /\bds-([a-z][a-z0-9-]*)\b/g;
80
+ const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
81
+ /**
82
+ * The tag around an offset. JSX puts `className` and `style` on separate
83
+ * lines constantly, so a line-scoped check would miss the common case; a
84
+ * bounded scan to the enclosing `<…>` is both simple and accurate.
85
+ */
86
+ function enclosingTag(src, at) {
87
+ const open = src.lastIndexOf("<", at);
88
+ if (open === -1)
89
+ return null;
90
+ let depth = 0;
91
+ for (let i = open; i < src.length && i < open + 4000; i++) {
92
+ const c = src[i];
93
+ if (c === "{")
94
+ depth++;
95
+ else if (c === "}")
96
+ depth--;
97
+ else if (c === ">" && depth <= 0)
98
+ return { text: src.slice(open, i), start: open };
99
+ }
100
+ return null;
101
+ }
102
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
103
+ export function findOverrides(source, recipes) {
104
+ const out = [];
105
+ const lines = source.split("\n");
106
+ const seen = new Set();
107
+ const record = (o) => {
108
+ const key = `${o.component}:${o.prop}:${o.line}`;
109
+ if (seen.has(key))
110
+ return;
111
+ seen.add(key);
112
+ out.push(o);
113
+ };
114
+ // ── JSX: a ds-* class and a local style on the same element ───────────────
115
+ for (const m of source.matchAll(DS_IN_CLASS)) {
116
+ const name = m[1];
117
+ const bind = recipes.get(name);
118
+ if (!bind)
119
+ continue;
120
+ const tag = enclosingTag(source, m.index ?? 0);
121
+ if (!tag)
122
+ continue;
123
+ // inline style={{ borderRadius: 4 }}
124
+ for (const s of tag.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
125
+ const prop = kebab(s[1]);
126
+ if (!bind.props.has(prop))
127
+ continue;
128
+ record({
129
+ component: name,
130
+ prop,
131
+ wrote: s[2].trim().replace(/^["']|["']$/g, ""),
132
+ recipe: bind.baseValues.get(prop) ?? null,
133
+ line: lineAt(source, tag.start + (s.index ?? 0)),
134
+ excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
135
+ });
136
+ }
137
+ // utility classes sitting beside the ds-* class
138
+ for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
139
+ const prop = utilityProp(cls[0]);
140
+ if (!prop || !bind.props.has(prop))
141
+ continue;
142
+ record({
143
+ component: name,
144
+ prop,
145
+ wrote: cls[0],
146
+ recipe: bind.baseValues.get(prop) ?? null,
147
+ line: lineAt(source, tag.start + (cls.index ?? 0)),
148
+ excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
149
+ });
150
+ }
151
+ }
152
+ // ── CSS: a rule whose selector reaches a ds-* component ───────────────────
153
+ for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
154
+ const selector = rule[1];
155
+ const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
156
+ const bind = hit ? recipes.get(hit[1]) : undefined;
157
+ if (!hit || !bind)
158
+ continue;
159
+ for (const decl of rule[2].matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)) {
160
+ const prop = decl[1].trim();
161
+ if (!bind.props.has(prop))
162
+ continue;
163
+ // Reading the system back is the opposite of overruling it.
164
+ if (decl[2].includes("var(--ds-"))
165
+ continue;
166
+ const at = (rule.index ?? 0) + rule[1].length + (decl.index ?? 0);
167
+ record({
168
+ component: hit[1],
169
+ prop,
170
+ wrote: decl[2].trim(),
171
+ recipe: bind.baseValues.get(prop) ?? null,
172
+ line: lineAt(source, at),
173
+ excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
174
+ });
175
+ }
176
+ }
177
+ return out.sort((a, b) => a.line - b.line);
178
+ }
179
+ /** Recipes from an installed `design-system.json`, as bindings. */
180
+ export function bindingsFromDocument(doc) {
181
+ const out = new Map();
182
+ const comps = doc?.components;
183
+ if (!comps)
184
+ return out;
185
+ for (const [name, recipe] of Object.entries(comps)) {
186
+ out.set(name, bindingsOf(recipe));
187
+ }
188
+ return out;
189
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * DOCTOR · finding the drift.
3
+ *
4
+ * Drift is the market's own word - every generator now promises "brand
5
+ * consistent, no drift" and not one of them checks. This is the check: a
6
+ * literal design value written by hand where the project's system already has
7
+ * a name for it.
8
+ *
9
+ * Text in, findings out. No filesystem, no AST, no network - a diagnosis that
10
+ * takes eight seconds and needs a build step is a diagnosis nobody runs.
11
+ */
12
+ import { tokenFor } from "./tokens.js";
13
+ const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
14
+ /** Lines we must not read as authorship: imports, and our own installed CSS. */
15
+ const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
16
+ /**
17
+ * Colour literals, in the two dialects a component file actually mixes:
18
+ * plain CSS values and Tailwind arbitrary values (`bg-[#2563eb]`).
19
+ * `#` inside a URL fragment or an id selector is excluded by requiring a full
20
+ * 3/6/8 digit run terminated by a non-hex character.
21
+ */
22
+ const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
23
+ /** `rounded-[14px]`, `border-radius: 14px`. Zero and full pills are idiom,
24
+ * not drift - nobody tokenizes `0` or `9999px`. */
25
+ const RADIUS = /(?:border-radius\s*:\s*|rounded(?:-[a-z]+)?-\[)(-?\d*\.?\d+)(px|rem|em)/g;
26
+ /** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`. */
27
+ const SPACING = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[|(?:padding|margin|gap)\s*:\s*)(-?\d*\.?\d+)(px|rem)/g;
28
+ /** A font stack written by hand rather than taken from the type scale. */
29
+ const FONT = /font-family\s*:\s*([^;}\n]+)/g;
30
+ /** Uses of the system. Coverage is meaningless without them. */
31
+ const TOKEN_USE = /var\(\s*--ds-[a-z0-9-]+/gi;
32
+ /** Under this, a radius or spacing value is idiom rather than a decision. */
33
+ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
34
+ export function scanSource(file, source, table) {
35
+ const findings = [];
36
+ let tokenUses = 0;
37
+ source.split("\n").forEach((raw, i) => {
38
+ const line = raw.trim();
39
+ const at = i + 1;
40
+ tokenUses += (line.match(TOKEN_USE) ?? []).length;
41
+ if (!line || IGNORE_LINE.test(line))
42
+ return;
43
+ // `rgba(${r}, ${g}, ${b}, ${a})` is code computing a colour, not a colour
44
+ // written by hand - flagging it would be telling someone to tokenize a
45
+ // variable. And the same literal twice in one declaration (a two-stop
46
+ // shadow) is one decision, so it is reported once.
47
+ const seen = new Set();
48
+ const push = (kind, literal) => {
49
+ if (literal.includes("$") || literal.includes("{"))
50
+ return;
51
+ const key = `${kind}:${literal}`;
52
+ if (seen.has(key))
53
+ return;
54
+ seen.add(key);
55
+ findings.push({
56
+ kind,
57
+ line: at,
58
+ literal,
59
+ token: tokenFor(table, literal),
60
+ excerpt: clip(line),
61
+ });
62
+ };
63
+ for (const m of line.matchAll(COLOR))
64
+ push("color", m[0]);
65
+ for (const m of line.matchAll(RADIUS)) {
66
+ const value = `${m[1]}${m[2]}`;
67
+ if (!IDIOM.has(value))
68
+ push("radius", value);
69
+ }
70
+ for (const m of line.matchAll(SPACING)) {
71
+ const value = `${m[1]}${m[2]}`;
72
+ if (!IDIOM.has(value))
73
+ push("spacing", value);
74
+ }
75
+ for (const m of line.matchAll(FONT)) {
76
+ const stack = m[1].trim();
77
+ // A stack already reading from the system is the point, not a problem.
78
+ if (stack.startsWith("var("))
79
+ continue;
80
+ push("font", stack);
81
+ }
82
+ });
83
+ return { file, findings, tokenUses };
84
+ }
85
+ export function diagnose(files) {
86
+ const flat = files.flatMap((f) => f.findings.map((x) => ({ ...x, file: f.file })));
87
+ const counts = {
88
+ color: 0,
89
+ radius: 0,
90
+ spacing: 0,
91
+ font: 0,
92
+ };
93
+ for (const f of flat)
94
+ counts[f.kind] += 1;
95
+ const tokenUses = files.reduce((n, f) => n + f.tokenUses, 0);
96
+ const total = tokenUses + flat.length;
97
+ const byLiteral = new Map();
98
+ for (const f of flat) {
99
+ const key = `${f.kind}:${f.literal.toLowerCase()}`;
100
+ const hit = byLiteral.get(key);
101
+ if (hit) {
102
+ hit.count += 1;
103
+ hit.files.add(f.file);
104
+ }
105
+ else {
106
+ byLiteral.set(key, {
107
+ kind: f.kind,
108
+ token: f.token,
109
+ count: 1,
110
+ files: new Set([f.file]),
111
+ });
112
+ }
113
+ }
114
+ const repeats = [...byLiteral.entries()]
115
+ .map(([key, v]) => ({
116
+ literal: key.slice(key.indexOf(":") + 1),
117
+ kind: v.kind,
118
+ token: v.token,
119
+ count: v.count,
120
+ files: v.files.size,
121
+ }))
122
+ .filter((r) => r.count > 1)
123
+ .sort((a, b) => b.count - a.count);
124
+ return {
125
+ files: files.filter((f) => f.findings.length > 0),
126
+ findings: flat,
127
+ counts,
128
+ named: flat.filter((f) => f.token).length,
129
+ tokenUses,
130
+ coverage: total === 0 ? 100 : Math.round((tokenUses / total) * 100),
131
+ scanned: files.length,
132
+ repeats,
133
+ };
134
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * DOCTOR · the installed system, read back out of the project.
3
+ *
4
+ * `add` writes `_synthesisui/ds/<slug>/tokens.css` and a `.lock` naming the
5
+ * pinned version. Doctor reads them so it can do the thing nothing else in
6
+ * this market does: not merely flag a hardcoded value, but say what the
7
+ * project's OWN system already calls it.
8
+ *
9
+ * #2563eb → --ds-color-semantic-info
10
+ *
11
+ * Pure and dependency-free on purpose: every function here takes text and
12
+ * returns data, so the whole diagnosis is testable without a filesystem.
13
+ */
14
+ export const EMPTY_TABLE = {
15
+ name: null,
16
+ slug: null,
17
+ version: null,
18
+ byName: new Map(),
19
+ byValue: new Map(),
20
+ };
21
+ /** Lowercase, collapse whitespace, expand #abc to #aabbcc - so `#FFF`,
22
+ * `#ffffff` and `# fff` all land on one key. */
23
+ export function normalizeValue(raw) {
24
+ const v = raw.trim().toLowerCase().replace(/\s+/g, " ");
25
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
26
+ if (short)
27
+ return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
28
+ // rgb(37 99 235) and rgb(37, 99, 235) are the same colour written twice.
29
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(v);
30
+ if (rgb) {
31
+ const parts = rgb[1].split(/[\s,/]+/).filter(Boolean);
32
+ return `rgb(${parts.join(" ")})`;
33
+ }
34
+ return v;
35
+ }
36
+ /**
37
+ * Pull `--ds-*: value;` declarations out of a stylesheet.
38
+ *
39
+ * A regex rather than a CSS parser: tokens.css is emitted by us, one
40
+ * declaration per line, and a dependency-free CLI that starts instantly is
41
+ * worth more here than tolerating stylesheets we did not write.
42
+ */
43
+ export function parseTokens(css) {
44
+ const out = new Map();
45
+ for (const m of css.matchAll(/(--ds-[a-z0-9-]+)\s*:\s*([^;}]+)[;}]/gi)) {
46
+ const name = m[1].toLowerCase();
47
+ const value = m[2].trim();
48
+ // A token pointing at another token is an alias, not a literal we can
49
+ // match a hardcoded colour against.
50
+ if (value.startsWith("var("))
51
+ continue;
52
+ if (!out.has(name))
53
+ out.set(name, value);
54
+ }
55
+ return out;
56
+ }
57
+ export function buildTable(input) {
58
+ const byName = parseTokens(input.css);
59
+ const byValue = new Map();
60
+ for (const [name, value] of byName) {
61
+ const key = normalizeValue(value);
62
+ const list = byValue.get(key);
63
+ if (list)
64
+ list.push(name);
65
+ else
66
+ byValue.set(key, [name]);
67
+ }
68
+ return {
69
+ name: input.lock?.name ?? null,
70
+ slug: input.lock?.slug ?? null,
71
+ version: input.lock?.version ?? null,
72
+ byName,
73
+ byValue,
74
+ };
75
+ }
76
+ /**
77
+ * The token that already holds this literal, if the project has one.
78
+ *
79
+ * Exact match only. A "nearest colour" guess would be the single most
80
+ * dangerous thing this tool could do: telling somebody their `#2563ec` is
81
+ * "basically" `--ds-color-semantic-info` invites a silent visual change, and
82
+ * a diagnosis nobody can trust is worse than no diagnosis. Near misses are
83
+ * reported as drift, not as a fix.
84
+ */
85
+ export function tokenFor(table, literal) {
86
+ const hit = table.byValue.get(normalizeValue(literal));
87
+ if (!hit || hit.length === 0)
88
+ return null;
89
+ // Semantic roles name intent; primitives name a shelf. Prefer intent.
90
+ const semantic = hit.find((n) => n.includes("-semantic-"));
91
+ return semantic ?? hit[0];
92
+ }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { add } from "./commands/add.js";
3
3
  import { advise } from "./commands/advise.js";
4
4
  import { clean } from "./commands/clean.js";
5
5
  import { component } from "./commands/component.js";
6
+ import { doctor } from "./commands/doctor.js";
6
7
  import { generate } from "./commands/generate.js";
7
8
  import { init } from "./commands/init.js";
8
9
  import { list } from "./commands/list.js";
@@ -25,6 +26,8 @@ Usage - deterministic, FREE:
25
26
  synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
26
27
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
27
28
  synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
29
+ synthesisui doctor [--strict] [--all] audit this repo for DRIFT: every design value written by
30
+ hand, and the token your system already has for it
28
31
 
29
32
  Usage - AI, USES CREDITS (login required):
30
33
  synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
@@ -48,6 +51,8 @@ Options:
48
51
  --instruction <s> refit: extra guidance for the adaptation
49
52
  --dry refit: adapt and print, but save nothing
50
53
  --force clean: apply the changes (without it, dry run)
54
+ --strict doctor: exit 1 when drift is found (for CI)
55
+ --all doctor: list every finding, not just the loudest files
51
56
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
52
57
  -h, --help this help
53
58
 
@@ -55,6 +60,8 @@ Examples:
55
60
  synthesisui login
56
61
  synthesisui init --target next
57
62
  synthesisui init --target next --ds halogen bootstrap + bring a system in
63
+ synthesisui doctor
64
+ synthesisui doctor --strict
58
65
  synthesisui list
59
66
  synthesisui add halogen
60
67
  synthesisui add halogen --version 3
@@ -110,6 +117,13 @@ async function main() {
110
117
  const registry = typeof flags.registry === "string" ? flags.registry : undefined;
111
118
  const dir = typeof flags.dir === "string" ? flags.dir : undefined;
112
119
  switch (command) {
120
+ case "doctor":
121
+ await doctor({
122
+ dir,
123
+ strict: flags.strict === true,
124
+ all: flags.all === true,
125
+ });
126
+ break;
113
127
  case "list":
114
128
  await list({ registry });
115
129
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.4.13",
3
+ "version": "0.6.0",
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": {
@@ -31,7 +31,8 @@
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json",
33
33
  "dev": "tsx src/index.ts",
34
- "prepublishOnly": "npm run build"
34
+ "prepublishOnly": "npm run build",
35
+ "test": "vitest run"
35
36
  },
36
37
  "license": "MIT"
37
38
  }
@@ -1,42 +0,0 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { readProjectConfig, resolveRegistry } from "../config.js";
4
- import { fetchPage } from "../registry.js";
5
- /**
6
- * Materializes a whole page from a DS template into the project (hybrid
7
- * codegen-first): the server codegens deterministic files, we write them, and
8
- * the agent refines them in place. The page uses the DS's `.ds-*` classes +
9
- * path classes; the co-located CSS (Next target) carries the responsive media
10
- * queries + the CSS-only hamburger, so it re-vests once `tokens.css` is in.
11
- */
12
- export async function page(slug, template, opts) {
13
- const base = resolveRegistry(opts.registry);
14
- const root = opts.dir ?? process.cwd();
15
- const config = await readProjectConfig(root);
16
- const target = opts.target === "general" || opts.target === "next"
17
- ? opts.target
18
- : config.target;
19
- console.log(`→ generating "${template}" from "${slug}" (${target}) …`);
20
- const generated = await fetchPage(base, slug, template, target, opts.version);
21
- // --out targets the page (1st file); sibling files (e.g. the CSS) land in the
22
- // same directory. Without --out, everything goes under <pagesDir>.
23
- const [pageFile, ...siblings] = generated.files;
24
- const pageRel = opts.out ?? join(config.pagesDir, pageFile.filename);
25
- const pageDir = dirname(join(root, pageRel));
26
- await mkdir(pageDir, { recursive: true });
27
- await writeFile(join(root, pageRel), pageFile.code, "utf8");
28
- console.log(`✓ wrote ${pageRel} (${slug} v${generated.version})`);
29
- for (const f of siblings) {
30
- const rel = opts.out
31
- ? join(dirname(pageRel), f.filename)
32
- : join(config.pagesDir, f.filename);
33
- await writeFile(join(root, rel), f.code, "utf8");
34
- console.log(`✓ wrote ${rel}`);
35
- }
36
- console.log("");
37
- console.log("Next steps:");
38
- console.log(` • ensure the DS is installed: synthesisui add ${slug} (provides tokens.css)`);
39
- console.log(` • @import "_synthesisui/ds/${slug}/tokens.css" in your global CSS`);
40
- console.log(" • refine the file: wire real data, split into components, swap placeholders");
41
- console.log(` • keep the data-ds="${slug}" wrapper and the ds-* / layout classes (stays on-system)`);
42
- }