guard-my-design-system 1.5.0 → 1.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 CHANGED
@@ -56,6 +56,16 @@ it updates that same comment. It never adds more comments:
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.5.0",
3
+ "version": "1.7.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": "7.0.0"
14
+ "roast-my-design-system": "8.4.5"
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, blankComments,
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,36 @@ 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');
109
+ // The added line with its comments blanked, the way the report and the
110
+ // live checks read a file before matching (roast 8.4.4): a class named in
111
+ // a comment paints nothing. Blanked from the whole file when it is at hand,
112
+ // so a block comment opened on an earlier line still counts as a comment.
113
+ const blanked = new Map();
114
+ const codeText = (file, lineNo, text) => {
115
+ const w = wholeText(file);
116
+ if (w == null) return blankComments(text);
117
+ if (!blanked.has(file)) blanked.set(file, blankComments(w).split('\n'));
118
+ // blanking keeps every character's place, so the file's line is the
119
+ // diff's line only if the lengths match; otherwise the file has moved on
120
+ const l = blanked.get(file)[lineNo - 1];
121
+ return l != null && l.length === text.length ? l : blankComments(text);
122
+ };
62
123
 
63
124
  // The system was learned from the tree that already CONTAINS these added
64
125
  // lines, so a new value would vouch for itself. A value is only "known"
@@ -67,7 +128,7 @@ export function judge(added, system, { readFile } = {}) {
67
128
  const addedLengths = new Map(), addedFaces = new Map();
68
129
  const addedExtras = { radius: new Map(), fontsize: new Map(), shadow: new Map() };
69
130
  for (const { file, line, text } of added) {
70
- if (exempt(file)) continue;
131
+ if (exempt(file) || outOfScope(file)) continue;
71
132
  const css = isStyleFile(file);
72
133
  if (!css && !isCodeFile(file)) continue;
73
134
  for (const s of extractStyling(text, { css }).spacing) {
@@ -127,7 +188,7 @@ export function judge(added, system, { readFile } = {}) {
127
188
  const findings = [];
128
189
 
129
190
  for (const { file, line, text } of added) {
130
- if (exempt(file)) continue;
191
+ if (exempt(file) || outOfScope(file)) continue;
131
192
  const css = isStyleFile(file);
132
193
  if (!css && !isCodeFile(file)) continue;
133
194
 
@@ -188,7 +249,12 @@ export function judge(added, system, { readFile } = {}) {
188
249
  // is whether the name lives anywhere ELSE.
189
250
  if (!css && componentsByName.size) {
190
251
  for (const name of definedComponents(text)) {
191
- const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file));
252
+ const variantOf = (f) => variants.find((v) => underAny(f, [v])) ?? null;
253
+ const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file))
254
+ // a registry keeps the same component in sibling variants, and a
255
+ // block installs alone: neither is a second Button
256
+ .filter((c) => !(variantOf(file) && variantOf(c.file) && variantOf(c.file) !== variantOf(file)))
257
+ .filter((c) => !(underAny(file, blockDirs) && underAny(c.file, blockDirs)));
192
258
  if (!elsewhere.length) continue;
193
259
  const best = [...elsewhere].sort((a, b) => b.usageCount - a.usageCount)[0];
194
260
  findings.push({
@@ -209,11 +275,33 @@ export function judge(added, system, { readFile } = {}) {
209
275
  });
210
276
  }
211
277
 
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
- });
278
+ // !important is the medium, not the mess, in two places the report also
279
+ // sets aside: a widget stylesheet that must beat its host page, and a
280
+ // selector aimed only at a library's own class names.
281
+ if (seen.important.length && !(css && isWidgetFile(file))) {
282
+ const sel = css ? selectorAt(wholeText(file), line) : null;
283
+ const classes = sel ? [...sel.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((m) => m[1]) : [];
284
+ const libraryAimed = classes.length > 0 && classes.every(isLibraryClass);
285
+ if (!libraryAimed) {
286
+ for (const _ of seen.important) {
287
+ findings.push({
288
+ file, line, kind: 'important', value: '!important',
289
+ advice: 'the cascade admitting defeat; raise specificity or fix the source order',
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ // A palette class where a theme variable exists (a shadcn kit in
296
+ // CSS-variable mode): paint from a tin. The same pattern the report
297
+ // counts per 100 files; here, per added line.
298
+ if (!css && prof.paletteReady) {
299
+ for (const m of codeText(file, line, text).matchAll(paletteRe)) {
300
+ findings.push({
301
+ file, line, kind: 'palette', value: m[0],
302
+ advice: `a theme token covers this; use it as the class (bg-primary, text-muted-foreground), or add one${prof.sheetFile ? ` to ${prof.sheetFile}` : ' to the theme'} once`,
303
+ });
304
+ }
217
305
  }
218
306
 
219
307
  // 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.