guard-my-design-system 1.4.1 → 1.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
@@ -50,12 +50,22 @@ it updates that same comment. It never adds more comments:
50
50
  the nearest existing value named.
51
51
  - **A typeface your system does not declare.**
52
52
  - **`!important`.**
53
- - **Arbitrary Tailwind values** such as `w-[137px]` and `mt-[37px]`.
53
+ - **Arbitrary Tailwind values** such as `w-[137px]` and `text-[10px]`. A bracket on a spacing utility, such as `mt-[37px]`, is reported as off-scale spacing instead, with the nearest scale step named.
54
54
  - **An inline `style={{ }}` block.** Styling written there is invisible to the
55
55
  system and to every agent that reads the file. Blocks built from variables
56
56
  are decided elsewhere, so they are left alone.
57
57
  - **A second definition of a component you already have.** The finding names
58
58
  the file that already defines it, and how many places use that one.
59
+ - **A palette colour where a theme variable exists.** On a shadcn repo whose
60
+ theme file holds the variables, `text-slate-500` in the app's own code is
61
+ flagged and the theme file named. Off on utility-class installs.
62
+
63
+ It reads the repo the way the roast report does. On a shadcn repo the
64
+ installed catalogue, installed registries and kit blocks are not judged:
65
+ `shadcn add` is not a sin. On a repo that publishes a shadcn registry only
66
+ the published folders are judged. `!important` in an embedded widget's
67
+ stylesheet, or on a selector made of a library's own class names, is the
68
+ medium and passes.
59
69
 
60
70
  It ignores everything that was already in the codebase. It asks one question
61
71
  of a change: does it make things worse?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guard-my-design-system",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Your design system dies one pull request at a time. This makes sure it doesn't. A guard that judges only the lines a change adds, against the system the repo already has, and names the on-system value the author probably meant.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "src/"
12
12
  ],
13
13
  "dependencies": {
14
- "roast-my-design-system": "6.0.1"
14
+ "roast-my-design-system": "7.8.0"
15
15
  },
16
16
  "keywords": [
17
17
  "design-system",
package/src/judge.mjs CHANGED
@@ -12,8 +12,39 @@ import {
12
12
  isCodeFile, isStyleFile, typefaceOf, GENERIC_FONTS,
13
13
  definedComponents, exemptReason,
14
14
  EXTRA_KINDS, extraValue, fontDeclarations,
15
+ WIDGET_CSS_RE, isLibraryClass, PALETTE_CLASS_RE,
15
16
  } from 'roast-my-design-system/engine';
16
17
 
18
+ // Folder membership, the way the engine's own splits do it.
19
+ const underAny = (file, dirs) => (dirs ?? []).some((d) => file === d || file.startsWith(`${d}/`) || file.endsWith(`/${d}`) || file.includes(`/${d}/`));
20
+
21
+ // The selector of the innermost block an added line sits in, read from the
22
+ // whole file: scan back from the line to the nearest unclosed "{" and take
23
+ // what precedes it. Null when the line is not inside a block.
24
+ function selectorAt(whole, lineNo) {
25
+ if (!whole) return null;
26
+ const lines = whole.split('\n');
27
+ // include the added line itself up to the declaration: the block often
28
+ // opens on the same line (`.cm-editor { font: x !important; }`)
29
+ const cur = lines[lineNo - 1] ?? '';
30
+ const cut = cur.search(/!\s*important/i);
31
+ const upto = lines.slice(0, Math.max(0, lineNo - 1)).join('\n') + '\n' + (cut >= 0 ? cur.slice(0, cut) : cur);
32
+ let depth = 0;
33
+ for (let i = upto.length - 1; i >= 0; i--) {
34
+ const ch = upto[i];
35
+ if (ch === '}') depth++;
36
+ else if (ch === '{') {
37
+ if (depth === 0) {
38
+ const before = upto.slice(0, i);
39
+ const start = Math.max(before.lastIndexOf('}'), before.lastIndexOf(';'), before.lastIndexOf('{'));
40
+ return before.slice(start + 1).trim().split('\n').pop().trim();
41
+ }
42
+ depth--;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
17
48
  // git prints diff paths from the repository root; the engine lists them from
18
49
  // the directory it scanned. When the guard runs in a subdirectory the two
19
50
  // disagree by a prefix, so a suffix match stands in for equality. It errs
@@ -59,6 +90,22 @@ function exemptFiles(added, readFile) {
59
90
  */
60
91
  export function judge(added, system, { readFile } = {}) {
61
92
  const tokenSet = new Set(system.tokens);
93
+ // How the repo was read, from the engine's own profiles (roast 7.8):
94
+ // installed code is not the change's sin, a registry is judged on what it
95
+ // publishes, and a palette class counts only where a theme variable exists.
96
+ const prof = system.profile ?? {};
97
+ const installed = prof.installedDirs ?? [];
98
+ const counted = prof.registry?.countedDirs ?? [];
99
+ const variants = prof.registry?.variants ?? [];
100
+ const blockDirs = prof.registry?.blockDirs ?? [];
101
+ const outOfScope = (file) => underAny(file, installed) || (counted.length > 0 && !underAny(file, counted));
102
+ const whole = new Map();
103
+ const wholeText = (file) => {
104
+ if (!whole.has(file)) { let t = null; if (readFile) { try { t = readFile(file); } catch { t = null; } } whole.set(file, t); }
105
+ return whole.get(file);
106
+ };
107
+ const isWidgetFile = (file) => underAny(file, prof.widgetDirs) || WIDGET_CSS_RE.test(wholeText(file) ?? '');
108
+ const paletteRe = new RegExp(PALETTE_CLASS_RE.source, 'g');
62
109
 
63
110
  // The system was learned from the tree that already CONTAINS these added
64
111
  // lines, so a new value would vouch for itself. A value is only "known"
@@ -67,7 +114,7 @@ export function judge(added, system, { readFile } = {}) {
67
114
  const addedLengths = new Map(), addedFaces = new Map();
68
115
  const addedExtras = { radius: new Map(), fontsize: new Map(), shadow: new Map() };
69
116
  for (const { file, line, text } of added) {
70
- if (exempt(file)) continue;
117
+ if (exempt(file) || outOfScope(file)) continue;
71
118
  const css = isStyleFile(file);
72
119
  if (!css && !isCodeFile(file)) continue;
73
120
  for (const s of extractStyling(text, { css }).spacing) {
@@ -127,7 +174,7 @@ export function judge(added, system, { readFile } = {}) {
127
174
  const findings = [];
128
175
 
129
176
  for (const { file, line, text } of added) {
130
- if (exempt(file)) continue;
177
+ if (exempt(file) || outOfScope(file)) continue;
131
178
  const css = isStyleFile(file);
132
179
  if (!css && !isCodeFile(file)) continue;
133
180
 
@@ -188,7 +235,12 @@ export function judge(added, system, { readFile } = {}) {
188
235
  // is whether the name lives anywhere ELSE.
189
236
  if (!css && componentsByName.size) {
190
237
  for (const name of definedComponents(text)) {
191
- const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file));
238
+ const variantOf = (f) => variants.find((v) => underAny(f, [v])) ?? null;
239
+ const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file))
240
+ // a registry keeps the same component in sibling variants, and a
241
+ // block installs alone: neither is a second Button
242
+ .filter((c) => !(variantOf(file) && variantOf(c.file) && variantOf(c.file) !== variantOf(file)))
243
+ .filter((c) => !(underAny(file, blockDirs) && underAny(c.file, blockDirs)));
192
244
  if (!elsewhere.length) continue;
193
245
  const best = [...elsewhere].sort((a, b) => b.usageCount - a.usageCount)[0];
194
246
  findings.push({
@@ -209,11 +261,33 @@ export function judge(added, system, { readFile } = {}) {
209
261
  });
210
262
  }
211
263
 
212
- for (const _ of seen.important) {
213
- findings.push({
214
- file, line, kind: 'important', value: '!important',
215
- advice: 'the cascade admitting defeat; raise specificity or fix the source order',
216
- });
264
+ // !important is the medium, not the mess, in two places the report also
265
+ // sets aside: a widget stylesheet that must beat its host page, and a
266
+ // selector aimed only at a library's own class names.
267
+ if (seen.important.length && !(css && isWidgetFile(file))) {
268
+ const sel = css ? selectorAt(wholeText(file), line) : null;
269
+ const classes = sel ? [...sel.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((m) => m[1]) : [];
270
+ const libraryAimed = classes.length > 0 && classes.every(isLibraryClass);
271
+ if (!libraryAimed) {
272
+ for (const _ of seen.important) {
273
+ findings.push({
274
+ file, line, kind: 'important', value: '!important',
275
+ advice: 'the cascade admitting defeat; raise specificity or fix the source order',
276
+ });
277
+ }
278
+ }
279
+ }
280
+
281
+ // A palette class where a theme variable exists (a shadcn kit in
282
+ // CSS-variable mode): paint from a tin. The same pattern the report
283
+ // counts per 100 files; here, per added line.
284
+ if (!css && prof.paletteReady) {
285
+ for (const m of text.matchAll(paletteRe)) {
286
+ findings.push({
287
+ file, line, kind: 'palette', value: m[0],
288
+ advice: `a theme variable covers this; use a semantic class such as bg-primary or text-muted-foreground, or add a variable${prof.sheetFile ? ` to ${prof.sheetFile}` : ''}`,
289
+ });
290
+ }
217
291
  }
218
292
 
219
293
  // The engine's fontDeclarations decides what a judgeable font value is
package/src/report.mjs CHANGED
@@ -15,6 +15,7 @@ const KIND_LABEL = {
15
15
  font: 'new typeface',
16
16
  inline: 'inline style block',
17
17
  component: 'second definition of',
18
+ palette: 'palette colour where a theme variable exists',
18
19
  };
19
20
 
20
21
  // Kinds whose label already says everything; printing the value repeats it.