synthesisui 0.5.0 → 0.7.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 +45 -2
- package/dist/commands/doctor.js +115 -11
- package/dist/doctor/overrides.js +206 -0
- package/dist/index.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,29 @@ npx synthesisui@latest doctor
|
|
|
68
68
|
21 #f1f3fa → --ds-color-gray-100
|
|
69
69
|
```
|
|
70
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
|
+
|
|
71
94
|
The last column is the point: not "you hardcoded a colour", but **the name
|
|
72
95
|
your own system already has for it**. Exact matches only - a tool that guesses
|
|
73
96
|
a near colour invites a silent visual change, and a diagnosis nobody trusts is
|
|
@@ -76,8 +99,28 @@ worse than none.
|
|
|
76
99
|
With no system installed it still finds every hand-written value and counts
|
|
77
100
|
the distinct ones. Runs offline, needs no account, writes nothing.
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
### What your system says
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
── What your system says about what you use ──────────────────────
|
|
106
|
+
|
|
107
|
+
ds-badge · 3 places
|
|
108
|
+
Badges whisper status in periwinkle; danger appears only for money at risk.
|
|
109
|
+
|
|
110
|
+
ds-button · 3 places
|
|
111
|
+
One indigo action per view; gold is reserved for moments of ceremony, never buttons.
|
|
112
|
+
Buttons speak quietly - sentence case, no exclamation.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The usage laws your team wrote, for the components this project actually uses,
|
|
116
|
+
ordered by how much you use them. They are prose, so nothing verifies them -
|
|
117
|
+
the value is putting them in front of whoever is touching the component. No
|
|
118
|
+
other tool is positioned to do it, because no other tool knows these laws
|
|
119
|
+
exist.
|
|
120
|
+
|
|
121
|
+
`--strict` exits 1 when drift is found, for CI. `--all` lists every finding
|
|
122
|
+
instead of the loudest files. `--laws` shows every law instead of the busiest
|
|
123
|
+
components.
|
|
81
124
|
|
|
82
125
|
## What `add` materializes
|
|
83
126
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
|
|
3
4
|
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
4
5
|
import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
|
|
5
6
|
import { body, section, snippet } from "../output.js";
|
|
@@ -58,7 +59,10 @@ async function* walk(dir) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
/** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock
|
|
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. */
|
|
62
66
|
async function loadSystem(root) {
|
|
63
67
|
const dsDir = join(root, "_synthesisui", "ds");
|
|
64
68
|
let slugs;
|
|
@@ -68,12 +72,13 @@ async function loadSystem(root) {
|
|
|
68
72
|
.map((e) => e.name);
|
|
69
73
|
}
|
|
70
74
|
catch {
|
|
71
|
-
return EMPTY_TABLE;
|
|
75
|
+
return { table: EMPTY_TABLE, recipes: new Map() };
|
|
72
76
|
}
|
|
73
77
|
// Several systems can live side by side; every token is prefixed --ds-, so
|
|
74
78
|
// reading them all is both correct and what the running app actually sees.
|
|
75
79
|
let css = "";
|
|
76
80
|
let lock = null;
|
|
81
|
+
const recipes = new Map();
|
|
77
82
|
for (const slug of slugs) {
|
|
78
83
|
const dir = join(dsDir, slug);
|
|
79
84
|
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
|
@@ -99,8 +104,22 @@ async function loadSystem(root) {
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
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
|
+
}
|
|
102
121
|
}
|
|
103
|
-
return buildTable({ css, lock });
|
|
122
|
+
return { table: buildTable({ css, lock }), recipes };
|
|
104
123
|
}
|
|
105
124
|
const KIND_LABEL = {
|
|
106
125
|
color: "colour",
|
|
@@ -113,7 +132,7 @@ function meter(pct, width = 24) {
|
|
|
113
132
|
const filled = Math.round((pct / 100) * width);
|
|
114
133
|
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
115
134
|
}
|
|
116
|
-
function verdict(d, hasSystem) {
|
|
135
|
+
function verdict(d, hasSystem, overruled) {
|
|
117
136
|
// Nothing found and nothing installed: a utility package, a config folder,
|
|
118
137
|
// the wrong directory. Selling a design system here would be noise.
|
|
119
138
|
if (!hasSystem && d.findings.length === 0) {
|
|
@@ -133,12 +152,26 @@ function verdict(d, hasSystem) {
|
|
|
133
152
|
body("Browse systems at https://www.synthesisui.com/gallery"),
|
|
134
153
|
];
|
|
135
154
|
}
|
|
136
|
-
if (d.findings.length === 0) {
|
|
155
|
+
if (d.findings.length === 0 && overruled === 0) {
|
|
137
156
|
return [
|
|
138
157
|
body("No drift. Every design value in this project comes from the"),
|
|
139
158
|
body("system. That is a rarer sentence than it sounds."),
|
|
140
159
|
];
|
|
141
160
|
}
|
|
161
|
+
// A report that accuses a component of being overruled and then closes with
|
|
162
|
+
// "no drift" is a report nobody believes twice.
|
|
163
|
+
if (d.findings.length === 0) {
|
|
164
|
+
return [
|
|
165
|
+
body("No loose values - everything is a token."),
|
|
166
|
+
body(overruled === 1
|
|
167
|
+
? "But one place takes a component the system defines and"
|
|
168
|
+
: `But ${overruled} places take a component the system defines and`),
|
|
169
|
+
body(overruled === 1
|
|
170
|
+
? "overrules it locally. Either the recipe should change, or"
|
|
171
|
+
: "overrule it locally. Either the recipe should change, or"),
|
|
172
|
+
body("that override should not be there."),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
142
175
|
const lines = [
|
|
143
176
|
body(`${d.named} of ${d.findings.length} already have a name in your system.`),
|
|
144
177
|
body("Those are the cheap ones: swap the literal for the token."),
|
|
@@ -150,13 +183,25 @@ function verdict(d, hasSystem) {
|
|
|
150
183
|
}
|
|
151
184
|
export async function doctor(opts) {
|
|
152
185
|
const root = resolve(opts.dir ?? process.cwd());
|
|
153
|
-
const table = await loadSystem(root);
|
|
186
|
+
const { table, recipes } = await loadSystem(root);
|
|
154
187
|
const hasSystem = table.byName.size > 0;
|
|
155
188
|
const reports = [];
|
|
189
|
+
const overrides = [];
|
|
190
|
+
const used = new Map();
|
|
191
|
+
const known = new Set(recipes.keys());
|
|
156
192
|
for await (const file of walk(root)) {
|
|
157
193
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
158
|
-
if (src)
|
|
159
|
-
|
|
194
|
+
if (!src)
|
|
195
|
+
continue;
|
|
196
|
+
const rel = relative(root, file);
|
|
197
|
+
reports.push(scanSource(rel, src, table));
|
|
198
|
+
if (recipes.size > 0) {
|
|
199
|
+
for (const o of findOverrides(src, recipes))
|
|
200
|
+
overrides.push({ ...o, file: rel });
|
|
201
|
+
for (const [name, n] of countComponents(src, known)) {
|
|
202
|
+
used.set(name, (used.get(name) ?? 0) + n);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
160
205
|
}
|
|
161
206
|
if (reports.length === 0) {
|
|
162
207
|
console.log(`\nNothing to read in ${root}.\n`);
|
|
@@ -167,7 +212,10 @@ export async function doctor(opts) {
|
|
|
167
212
|
console.log(body(hasSystem
|
|
168
213
|
? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
|
|
169
214
|
: `No system installed - ${d.scanned} files read`));
|
|
170
|
-
|
|
215
|
+
// 0 of 0 is not a perfect score, it is an empty measurement - printing a
|
|
216
|
+
// full bar there would be the report's first lie.
|
|
217
|
+
const measurable = d.tokenUses + d.findings.length > 0;
|
|
218
|
+
if (hasSystem && measurable) {
|
|
171
219
|
console.log("");
|
|
172
220
|
console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
|
|
173
221
|
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
|
|
@@ -215,10 +263,66 @@ export async function doctor(opts) {
|
|
|
215
263
|
console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
|
|
216
264
|
}
|
|
217
265
|
}
|
|
266
|
+
if (overrides.length > 0) {
|
|
267
|
+
console.log(section("Overruled"));
|
|
268
|
+
console.log(body(`${overrides.length} place${overrides.length === 1 ? "" : "s"} where the code takes a component`));
|
|
269
|
+
console.log(body("the system defines, and then overrules it locally."));
|
|
270
|
+
console.log("");
|
|
271
|
+
const byFile = new Map();
|
|
272
|
+
for (const o of overrides) {
|
|
273
|
+
const list = byFile.get(o.file);
|
|
274
|
+
if (list)
|
|
275
|
+
list.push(o);
|
|
276
|
+
else
|
|
277
|
+
byFile.set(o.file, [o]);
|
|
278
|
+
}
|
|
279
|
+
const shown = opts.all ? [...byFile] : [...byFile].slice(0, 6);
|
|
280
|
+
for (const [file, list] of shown) {
|
|
281
|
+
console.log(body(file));
|
|
282
|
+
for (const o of opts.all ? list : list.slice(0, 3)) {
|
|
283
|
+
console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
|
|
284
|
+
if (o.recipe)
|
|
285
|
+
console.log(` the recipe binds ${o.recipe}`);
|
|
286
|
+
}
|
|
287
|
+
if (!opts.all && list.length > 3) {
|
|
288
|
+
console.log(` +${list.length - 3} more`);
|
|
289
|
+
}
|
|
290
|
+
console.log("");
|
|
291
|
+
}
|
|
292
|
+
if (byFile.size > shown.length) {
|
|
293
|
+
console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// The system's own words, for the components this project actually uses.
|
|
297
|
+
// Prose, so nothing verifies it - the value is putting it in front of
|
|
298
|
+
// whoever is touching the component, which no other tool is positioned to
|
|
299
|
+
// do because no other tool knows these laws exist.
|
|
300
|
+
const inUse = [...used.entries()]
|
|
301
|
+
.filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
|
|
302
|
+
.sort((a, b) => b[1] - a[1]);
|
|
303
|
+
if (inUse.length > 0) {
|
|
304
|
+
console.log(section("What your system says about what you use"));
|
|
305
|
+
const shownComps = opts.laws || opts.all ? inUse : inUse.slice(0, 4);
|
|
306
|
+
for (const [name, count] of shownComps) {
|
|
307
|
+
const laws = recipes.get(name)?.usage ?? [];
|
|
308
|
+
const shown = opts.laws || opts.all ? laws : laws.slice(0, 2);
|
|
309
|
+
console.log(body(`ds-${name} · ${count} place${count === 1 ? "" : "s"}`));
|
|
310
|
+
for (const law of shown)
|
|
311
|
+
console.log(` ${law}`);
|
|
312
|
+
if (laws.length > shown.length) {
|
|
313
|
+
console.log(` +${laws.length - shown.length} more`);
|
|
314
|
+
}
|
|
315
|
+
console.log("");
|
|
316
|
+
}
|
|
317
|
+
if (inUse.length > shownComps.length) {
|
|
318
|
+
console.log(body(`+${inUse.length - shownComps.length} more components carry laws. Run with --laws.`));
|
|
319
|
+
console.log("");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
218
322
|
console.log(section("What this means"));
|
|
219
|
-
for (const line of verdict(d, hasSystem))
|
|
323
|
+
for (const line of verdict(d, hasSystem, overrides.length))
|
|
220
324
|
console.log(line);
|
|
221
325
|
console.log("");
|
|
222
|
-
if (opts.strict && d.findings.length > 0)
|
|
326
|
+
if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
|
|
223
327
|
process.exitCode = 1;
|
|
224
328
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
const usage = Array.isArray(r.usage)
|
|
50
|
+
? r.usage.filter((x) => typeof x === "string")
|
|
51
|
+
: [];
|
|
52
|
+
return { props, baseValues, usage };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* How many places each known component is used. An inventory nobody has today:
|
|
56
|
+
* a design system's own report can say "ds-button, 14 places" because it knows
|
|
57
|
+
* which names are components rather than guessing at class strings.
|
|
58
|
+
*/
|
|
59
|
+
export function countComponents(source, known) {
|
|
60
|
+
const out = new Map();
|
|
61
|
+
for (const m of source.matchAll(DS_IN_CLASS)) {
|
|
62
|
+
if (!known.has(m[1]))
|
|
63
|
+
continue;
|
|
64
|
+
out.set(m[1], (out.get(m[1]) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Tailwind utilities that plainly set a CSS property. Deliberately short:
|
|
70
|
+
* every entry here can produce an accusation, so anything ambiguous is left
|
|
71
|
+
* out. Being quiet and right beats being loud.
|
|
72
|
+
*/
|
|
73
|
+
const UTILITY = [
|
|
74
|
+
[
|
|
75
|
+
/^rounded(-(?:sm|md|lg|xl|2xl|3xl|full|none))?$|^rounded-\[/,
|
|
76
|
+
"border-radius",
|
|
77
|
+
],
|
|
78
|
+
[/^p-|^px-|^py-|^pt-|^pr-|^pb-|^pl-/, "padding"],
|
|
79
|
+
[/^gap-/, "gap"],
|
|
80
|
+
[/^bg-\[|^bg-(?!clip|blend|origin|repeat|fixed|local|scroll)/, "background"],
|
|
81
|
+
[/^shadow(-|$)/, "box-shadow"],
|
|
82
|
+
[
|
|
83
|
+
/^font-(?:thin|light|normal|medium|semibold|bold|extrabold|black)$/,
|
|
84
|
+
"font-weight",
|
|
85
|
+
],
|
|
86
|
+
[/^(?:text|font)-\[/, "font-size"],
|
|
87
|
+
[/^tracking-/, "letter-spacing"],
|
|
88
|
+
[/^border-\[|^border-\d/, "border-width"],
|
|
89
|
+
];
|
|
90
|
+
function utilityProp(cls) {
|
|
91
|
+
for (const [re, prop] of UTILITY)
|
|
92
|
+
if (re.test(cls))
|
|
93
|
+
return prop;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const DS_IN_CLASS = /\bds-([a-z][a-z0-9-]*)\b/g;
|
|
97
|
+
const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
|
|
98
|
+
/**
|
|
99
|
+
* The tag around an offset. JSX puts `className` and `style` on separate
|
|
100
|
+
* lines constantly, so a line-scoped check would miss the common case; a
|
|
101
|
+
* bounded scan to the enclosing `<…>` is both simple and accurate.
|
|
102
|
+
*/
|
|
103
|
+
function enclosingTag(src, at) {
|
|
104
|
+
const open = src.lastIndexOf("<", at);
|
|
105
|
+
if (open === -1)
|
|
106
|
+
return null;
|
|
107
|
+
let depth = 0;
|
|
108
|
+
for (let i = open; i < src.length && i < open + 4000; i++) {
|
|
109
|
+
const c = src[i];
|
|
110
|
+
if (c === "{")
|
|
111
|
+
depth++;
|
|
112
|
+
else if (c === "}")
|
|
113
|
+
depth--;
|
|
114
|
+
else if (c === ">" && depth <= 0)
|
|
115
|
+
return { text: src.slice(open, i), start: open };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
120
|
+
export function findOverrides(source, recipes) {
|
|
121
|
+
const out = [];
|
|
122
|
+
const lines = source.split("\n");
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const record = (o) => {
|
|
125
|
+
const key = `${o.component}:${o.prop}:${o.line}`;
|
|
126
|
+
if (seen.has(key))
|
|
127
|
+
return;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
out.push(o);
|
|
130
|
+
};
|
|
131
|
+
// ── JSX: a ds-* class and a local style on the same element ───────────────
|
|
132
|
+
for (const m of source.matchAll(DS_IN_CLASS)) {
|
|
133
|
+
const name = m[1];
|
|
134
|
+
const bind = recipes.get(name);
|
|
135
|
+
if (!bind)
|
|
136
|
+
continue;
|
|
137
|
+
const tag = enclosingTag(source, m.index ?? 0);
|
|
138
|
+
if (!tag)
|
|
139
|
+
continue;
|
|
140
|
+
// inline style={{ borderRadius: 4 }}
|
|
141
|
+
for (const s of tag.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
|
|
142
|
+
const prop = kebab(s[1]);
|
|
143
|
+
if (!bind.props.has(prop))
|
|
144
|
+
continue;
|
|
145
|
+
record({
|
|
146
|
+
component: name,
|
|
147
|
+
prop,
|
|
148
|
+
wrote: s[2].trim().replace(/^["']|["']$/g, ""),
|
|
149
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
150
|
+
line: lineAt(source, tag.start + (s.index ?? 0)),
|
|
151
|
+
excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// utility classes sitting beside the ds-* class
|
|
155
|
+
for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
|
|
156
|
+
const prop = utilityProp(cls[0]);
|
|
157
|
+
if (!prop || !bind.props.has(prop))
|
|
158
|
+
continue;
|
|
159
|
+
record({
|
|
160
|
+
component: name,
|
|
161
|
+
prop,
|
|
162
|
+
wrote: cls[0],
|
|
163
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
164
|
+
line: lineAt(source, tag.start + (cls.index ?? 0)),
|
|
165
|
+
excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ── CSS: a rule whose selector reaches a ds-* component ───────────────────
|
|
170
|
+
for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
171
|
+
const selector = rule[1];
|
|
172
|
+
const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
|
|
173
|
+
const bind = hit ? recipes.get(hit[1]) : undefined;
|
|
174
|
+
if (!hit || !bind)
|
|
175
|
+
continue;
|
|
176
|
+
for (const decl of rule[2].matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)) {
|
|
177
|
+
const prop = decl[1].trim();
|
|
178
|
+
if (!bind.props.has(prop))
|
|
179
|
+
continue;
|
|
180
|
+
// Reading the system back is the opposite of overruling it.
|
|
181
|
+
if (decl[2].includes("var(--ds-"))
|
|
182
|
+
continue;
|
|
183
|
+
const at = (rule.index ?? 0) + rule[1].length + (decl.index ?? 0);
|
|
184
|
+
record({
|
|
185
|
+
component: hit[1],
|
|
186
|
+
prop,
|
|
187
|
+
wrote: decl[2].trim(),
|
|
188
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
189
|
+
line: lineAt(source, at),
|
|
190
|
+
excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out.sort((a, b) => a.line - b.line);
|
|
195
|
+
}
|
|
196
|
+
/** Recipes from an installed `design-system.json`, as bindings. */
|
|
197
|
+
export function bindingsFromDocument(doc) {
|
|
198
|
+
const out = new Map();
|
|
199
|
+
const comps = doc?.components;
|
|
200
|
+
if (!comps)
|
|
201
|
+
return out;
|
|
202
|
+
for (const [name, recipe] of Object.entries(comps)) {
|
|
203
|
+
out.set(name, bindingsOf(recipe));
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,8 @@ Usage - deterministic, FREE:
|
|
|
27
27
|
synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
|
|
28
28
|
synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
|
|
29
29
|
synthesisui doctor [--strict] [--all] audit this repo for DRIFT: every design value written by
|
|
30
|
-
hand,
|
|
30
|
+
hand, the token your system already has for it, and the
|
|
31
|
+
laws your system carries for what you use
|
|
31
32
|
|
|
32
33
|
Usage - AI, USES CREDITS (login required):
|
|
33
34
|
synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
|
|
@@ -53,6 +54,7 @@ Options:
|
|
|
53
54
|
--force clean: apply the changes (without it, dry run)
|
|
54
55
|
--strict doctor: exit 1 when drift is found (for CI)
|
|
55
56
|
--all doctor: list every finding, not just the loudest files
|
|
57
|
+
--laws doctor: show every usage law, not just the busiest components
|
|
56
58
|
--out <path> output path for the generated template (default: <pagesDir>/<file>)
|
|
57
59
|
-h, --help this help
|
|
58
60
|
|
|
@@ -122,6 +124,7 @@ async function main() {
|
|
|
122
124
|
dir,
|
|
123
125
|
strict: flags.strict === true,
|
|
124
126
|
all: flags.all === true,
|
|
127
|
+
laws: flags.laws === true,
|
|
125
128
|
});
|
|
126
129
|
break;
|
|
127
130
|
case "list":
|
package/package.json
CHANGED