guard-my-design-system 1.3.4 → 1.4.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
@@ -8,15 +8,19 @@ The guard checks pull requests for design-system drift. It looks only at the
8
8
  lines a change adds. It never judges the code that was already there. For each
9
9
  problem it finds, it names the closest value your system already has:
10
10
 
11
- > `Card.tsx:24` — new colour `#4a7be8`. Nearest token: `var(--blue-500)`, `#3b6fe0`.
11
+ > `Card.tsx:24` · new colour `#4a7be8`. Nearest token: `var(--blue-500)`, `#3b6fe0`.
12
12
  >
13
- > `site.css:31` — new spacing value `13px`. Nearest existing value: `12px`.
13
+ > `site.css:31` · new spacing value `13px`. Nearest existing value: `12px`.
14
14
  >
15
- > `site.css:32` — new border radius `5px`. Nearest existing value: `6px`.
15
+ > `site.css:32` · new border radius `5px`. Nearest existing value: `6px`.
16
16
  >
17
- > `site.css:33` — new typeface `Comic Sans MS`. First typeface declared in this codebase.
17
+ > `site.css:33` · new typeface `Comic Sans MS`. First typeface declared in this codebase.
18
18
  >
19
- > `site.css:35` — `!important`. The cascade admitting defeat; raise specificity or fix the source order.
19
+ > `site.css:35` · `!important`. The cascade admitting defeat; raise specificity or fix the source order.
20
+ >
21
+ > `Panel.tsx:12` · inline style block. The values are invisible to the system and to every agent that reads the file; move them to classes or tokens.
22
+ >
23
+ > `ButtonV2.tsx:1` · second definition of `Button`. Import components/Button.tsx rather than starting a second one.
20
24
 
21
25
  It learns your design system by scanning your repository with the
22
26
  [roast-my-design-system](https://github.com/gregkozakiewicz/roast-my-design-system)
@@ -36,7 +40,9 @@ it updates that same comment. It never adds more comments:
36
40
  ## What it catches
37
41
 
38
42
  - **A hard-coded colour where a token exists.** The finding names the token:
39
- `var(--blue-500)`, not just a hex code.
43
+ `var(--blue-500)`, not just a hex code. This works across colour notations:
44
+ a hex stray is matched to an hsl or oklch token, including shadcn's
45
+ bare-triplet variables.
40
46
  - **A spacing value your codebase has never used**, with the nearest existing
41
47
  step named.
42
48
  - **A border radius, font size or shadow your system does not declare**, with
@@ -44,6 +50,11 @@ it updates that same comment. It never adds more comments:
44
50
  - **A typeface your system does not declare.**
45
51
  - **`!important`.**
46
52
  - **Arbitrary Tailwind values** such as `w-[137px]` and `mt-[37px]`.
53
+ - **An inline `style={{ }}` block.** Styling written there is invisible to the
54
+ system and to every agent that reads the file. Blocks built from variables
55
+ are decided elsewhere, so they are left alone.
56
+ - **A second definition of a component you already have.** The finding names
57
+ the file that already defines it, and how many places use that one.
47
58
 
48
59
  It ignores everything that was already in the codebase. It asks one question
49
60
  of a change: does it make things worse?
@@ -172,10 +183,14 @@ updating PR comment works on GitHub only, for now.
172
183
  time. No AI model is involved.
173
184
  - **Read-only. No network. No telemetry.** Everything runs on your machine or
174
185
  your CI runner. Nothing about your code leaves it.
175
- - **Fair exemptions, inherited from roast.** Email and print styling must be
176
- inline, so the guard never flags it. Files that draw SVG artwork are not
177
- judged on their colours. Defining a new token is extending the system, not
178
- a problem.
186
+ - **Fair exemptions, shared with roast.** Some files cannot be on-system, so
187
+ judging them would be crying wolf. Email and print styling has to be inline,
188
+ because there is no cascade to inherit. An OG card or a PDF invoice is a
189
+ picture drawn with code. A canvas renderer draws pixels. A file that draws
190
+ SVG is artwork, not interface. The guard reads that list from the roast
191
+ engine rather than keeping its own, so the two can never drift apart and
192
+ give you different answers about the same file. Defining a new token is
193
+ extending the system, not a problem.
179
194
  - **Every finding comes with a fix.** The guard names the on-system value the
180
195
  author probably meant, so most fixes take under a minute and no meeting.
181
196
 
package/index.mjs CHANGED
@@ -53,7 +53,7 @@ let added;
53
53
  try {
54
54
  added = addedLines(cwd, base);
55
55
  } catch (e) {
56
- console.error(`guard: git diff failed — ${e.message.split('\n')[0]}`);
56
+ console.error(`guard: git diff failed. ${e.message.split('\n')[0]}`);
57
57
  process.exit(2);
58
58
  }
59
59
 
@@ -71,7 +71,17 @@ ignorePrefixes = ignorePrefixes.map((p) => p.replace(/^\.?\//, '').replace(/\/?$
71
71
  const judged = added.filter(({ file }) => !ignorePrefixes.some((p) => (file + '/').startsWith(p)));
72
72
 
73
73
  const system = learnSystem(cwd, { exclude });
74
- let findings = judge(judged, system);
74
+ // The judge asks for whole files when deciding what to leave alone: a satori
75
+ // import or an SVG drawing sits at the top of a file the diff never touches.
76
+ const wholeFile = new Map();
77
+ const readWhole = (file) => {
78
+ if (!wholeFile.has(file)) {
79
+ try { wholeFile.set(file, readFileSync(resolve(cwd, file), 'utf8')); }
80
+ catch { wholeFile.set(file, null); }
81
+ }
82
+ return wholeFile.get(file);
83
+ };
84
+ let findings = judge(judged, system, { readFile: readWhole });
75
85
 
76
86
  // The escape hatch: a `guard-ignore-next-line` comment silences every finding
77
87
  // on the line below it. Checked against the file as it stands (not just the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guard-my-design-system",
3
- "version": "1.3.4",
3
+ "version": "1.4.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": "5.7.2"
14
+ "roast-my-design-system": "5.11.0"
15
15
  },
16
16
  "keywords": [
17
17
  "design-system",
package/src/judge.mjs CHANGED
@@ -10,55 +10,60 @@
10
10
  import {
11
11
  extractStyling, normalizeHex, nearestColor, nearestLength,
12
12
  isCodeFile, isStyleFile, typefaceOf, GENERIC_FONTS,
13
+ definedComponents, exemptReason,
14
+ EXTRA_KINDS, FONT_LINE_RE, extraValue,
13
15
  } from 'roast-my-design-system/engine';
14
16
 
15
- // Same honesty exemptions the engine applies: email and print styling must be
16
- // inline, always. Artwork-named files (Icon, Logo, Badge…) are exempt only
17
- // when their added lines actually draw SVG — a Badge component that is plain
18
- // styled UI gets guarded like everything else.
19
- const EMAIL_PRINT_RE = /email|(^|[/.])print([/.]|$)/i;
20
- const ARTWORK_NAME_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|artwork)[\w.-]*\.(tsx|jsx)$/i;
21
- const SVG_MARKUP_RE = /<(svg|path|rect|circle|ellipse|polygon|defs|mask)\b/i;
22
-
23
- function exemptFiles(added) {
24
- const svgish = new Set();
25
- for (const { file, text } of added) {
26
- if (ARTWORK_NAME_RE.test(file) && SVG_MARKUP_RE.test(text)) svgish.add(file);
17
+ // git prints diff paths from the repository root; the engine lists them from
18
+ // the directory it scanned. When the guard runs in a subdirectory the two
19
+ // disagree by a prefix, so a suffix match stands in for equality. It errs
20
+ // towards calling them the same file, which errs towards silence.
21
+ const samePath = (a, b) => a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
22
+
23
+ // The honesty exemptions come from the engine now: email and print styling
24
+ // that must be inline, OG cards and PDF invoices, pixel renderers, and artwork
25
+ // that actually draws. This file used to keep its own copy and claim in a
26
+ // comment that the engine applied the same one. It did not, and that gap is
27
+ // how roast --check came to raise findings on email templates. One list, read
28
+ // from the doorway, so the claim is true by construction.
29
+
30
+ /**
31
+ * Which files to leave alone. The whole file decides, not the added lines: a
32
+ * satori import or an SVG drawing sits at the top of a file a diff may never
33
+ * touch. readFile is how the caller hands over the working tree; without one
34
+ * the added lines stand in, which sees less and so exempts less.
35
+ */
36
+ function exemptFiles(added, readFile) {
37
+ const text = new Map();
38
+ for (const { file } of added) {
39
+ if (text.has(file)) continue;
40
+ let whole = null;
41
+ if (readFile) { try { whole = readFile(file); } catch { whole = null; } }
42
+ text.set(file, whole ?? added.filter((a) => a.file === file).map((a) => a.text).join('\n'));
27
43
  }
28
- return (file) => EMAIL_PRINT_RE.test(file) || svgish.has(file);
44
+ const verdict = new Map();
45
+ return (file) => {
46
+ if (!verdict.has(file)) verdict.set(file, Boolean(exemptReason(file, text.get(file) ?? '')));
47
+ return verdict.get(file);
48
+ };
29
49
  }
30
50
 
31
- const FONT_LINE_RE = /font-family\s*:\s*([^;{}]+)/i;
32
-
33
- // The other declarations the engine harvests and the guard judges in style
34
- // files. One regex per kind; the whole trimmed value is the unit of
35
- // comparison, exactly as the harvest counts it.
36
- const EXTRA_KINDS = [
37
- { kind: 'radius', re: /border-radius\s*:\s*([^;{}]+)/i, learned: 'radii' },
38
- { kind: 'fontsize', re: /(?:^|[^-\w])font-size\s*:\s*([^;{}]+)/i, learned: 'fontSizes' },
39
- { kind: 'shadow', re: /box-shadow\s*:\s*([^;{}]+)/i, learned: 'shadows' },
40
- ];
41
- // Disciplined values that are never sins: token use, resets, inheritance.
42
- const BENIGN_VALUE_RE = /^(var\(--[\w-]+\)|inherit|initial|unset|none|normal|0)$/i;
43
- const extraValue = (re, text) => {
44
- const m = re.exec(text);
45
- if (!m) return null;
46
- const v = m[1].trim().replace(/\s+/g, ' ');
47
- return BENIGN_VALUE_RE.test(v) ? null : v;
48
- };
51
+ // Radius, font size and shadow, and what counts as a disciplined value, also
52
+ // come from the engine, so the harvest and the guard measure the same thing.
49
53
 
50
54
  /**
51
55
  * Judge added lines against the learned system.
52
56
  * Returns [{ file, line, kind, value, advice }] sorted by file then line.
53
- * kinds: color | spacing | arbitrary | important | font
57
+ * kinds: color | spacing | radius | fontsize | shadow | arbitrary |
58
+ * important | font | inline | component
54
59
  */
55
- export function judge(added, system) {
60
+ export function judge(added, system, { readFile } = {}) {
56
61
  const tokenSet = new Set(system.tokens);
57
62
 
58
63
  // The system was learned from the tree that already CONTAINS these added
59
64
  // lines, so a new value would vouch for itself. A value is only "known"
60
65
  // if the repo uses it more times than this change added it.
61
- const exempt = exemptFiles(added);
66
+ const exempt = exemptFiles(added, readFile);
62
67
  const addedLengths = new Map(), addedFaces = new Map();
63
68
  const addedExtras = { radius: new Map(), fontsize: new Map(), shadow: new Map() };
64
69
  for (const { file, line, text } of added) {
@@ -90,7 +95,10 @@ export function judge(added, system) {
90
95
  // "use var(--blue-500)", not "go hunt this hex": name a value when the
91
96
  // system defines it as a custom property.
92
97
  const named = (value) => {
93
- const n = system.tokenNames?.[value];
98
+ // shadcn-style tokens are defined as bare triplets (--primary: 222.2 47.4%
99
+ // 11.2%) but normalised to hsl(...); try the unwrapped form too.
100
+ const n = system.tokenNames?.[value]
101
+ ?? system.tokenNames?.[value.replace(/^hsla?\((.*)\)$/i, '$1')];
94
102
  return n ? `var(${n}), ${value}` : value;
95
103
  };
96
104
  const faceCounts = new Map();
@@ -101,6 +109,18 @@ export function judge(added, system) {
101
109
  const knownFaces = new Set(
102
110
  [...faceCounts].filter(([face, n]) => n > (addedFaces.get(face) ?? 0)).map(([face]) => face)
103
111
  );
112
+ // Components the repo already defines, by name. Pages are routes rather than
113
+ // reusable parts, so two of a name there is not a second Button.
114
+ const componentsByName = new Map();
115
+ if (definedComponents && Array.isArray(system.components)) {
116
+ for (const c of system.components) {
117
+ if (c.isPage) continue;
118
+ const list = componentsByName.get(c.name) ?? [];
119
+ list.push(c);
120
+ componentsByName.set(c.name, list);
121
+ }
122
+ }
123
+
104
124
  const findings = [];
105
125
 
106
126
  for (const { file, line, text } of added) {
@@ -118,7 +138,7 @@ export function judge(added, system) {
118
138
  advice: near && near.distance <= 48
119
139
  ? `nearest token: ${named(near.value)}`
120
140
  : system.tokenFile
121
- ? `no token resembles it — if it is a real decision, it belongs in ${system.tokenFile}`
141
+ ? `no token resembles it, and if it is a real decision it belongs in ${system.tokenFile}`
122
142
  : 'no token layer found to compare against',
123
143
  });
124
144
  }
@@ -148,6 +168,37 @@ export function judge(added, system) {
148
168
  }
149
169
  }
150
170
 
171
+ // Styling inside style={{ }} is invisible to the system and to every
172
+ // agent that reads the file, so it can never be on-system by definition.
173
+ // Only static blocks count; extractStyling already ignores the ones built
174
+ // from variables, where the values are decided elsewhere.
175
+ for (const _ of seen.inlineBlocks) {
176
+ findings.push({
177
+ file, line, kind: 'inline', value: 'style={{ }}',
178
+ advice: 'the values are invisible to the system and to every agent that reads the file; move them to classes or tokens',
179
+ });
180
+ }
181
+
182
+ // A hand-rolled second <Button> is the most expensive thing a pull request
183
+ // can add, and it was the one thing the guard could not see. The scan
184
+ // includes this change, so the new copy is in the ledger too: what counts
185
+ // is whether the name lives anywhere ELSE.
186
+ if (!css && componentsByName.size) {
187
+ for (const name of definedComponents(text)) {
188
+ const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file));
189
+ if (!elsewhere.length) continue;
190
+ const best = [...elsewhere].sort((a, b) => b.usageCount - a.usageCount)[0];
191
+ findings.push({
192
+ file, line, kind: 'component', value: name,
193
+ // never open the advice with the path: the report capitalises the
194
+ // first letter, and a capitalised path is the wrong path
195
+ advice: elsewhere.length > 1
196
+ ? `${elsewhere.length} other files define it too; import ${best.file}, the one the codebase leans on`
197
+ : `import ${best.file} rather than starting a second one${best.usageCount ? `, which ${best.usageCount} place${best.usageCount === 1 ? '' : 's'} already do` : ''}`,
198
+ });
199
+ }
200
+ }
201
+
151
202
  for (const a of seen.arbitrary) {
152
203
  findings.push({
153
204
  file, line, kind: 'arbitrary', value: a.value,
package/src/report.mjs CHANGED
@@ -13,8 +13,13 @@ const KIND_LABEL = {
13
13
  arbitrary: 'arbitrary Tailwind value',
14
14
  important: '!important',
15
15
  font: 'new typeface',
16
+ inline: 'inline style block',
17
+ component: 'second definition of',
16
18
  };
17
19
 
20
+ // Kinds whose label already says everything; printing the value repeats it.
21
+ const VALUELESS = new Set(['important', 'inline']);
22
+
18
23
  const FOOTER = 'Full picture of the whole codebase: `npx roast-my-design-system`';
19
24
 
20
25
  export function terminalReport(findings) {
@@ -23,7 +28,7 @@ export function terminalReport(findings) {
23
28
  }
24
29
  const lines = [`guard-my-design-system: ${findings.length} new issue${findings.length === 1 ? '' : 's'} in this change\n`];
25
30
  for (const f of findings) {
26
- lines.push(` ${f.file}:${f.line} — ${KIND_LABEL[f.kind]} ${f.value === '!important' ? '' : f.value}`.trimEnd() + `. ${capitalise(f.advice)}.`);
31
+ lines.push(` ${f.file}:${f.line} · ${KIND_LABEL[f.kind]} ${VALUELESS.has(f.kind) ? '' : f.value}`.trimEnd() + `. ${capitalise(f.advice)}.`);
27
32
  }
28
33
  lines.push('');
29
34
  lines.push(' Only lines added in this change were counted. The existing codebase was not judged.');
@@ -43,8 +48,7 @@ export function markdownReport(findings) {
43
48
  }
44
49
  const out = [`**🛡 guard-my-design-system: ${findings.length} new issue${findings.length === 1 ? '' : 's'} in this pull request**`, ''];
45
50
  for (const f of findings) {
46
- const val = f.kind === 'important' ? '`!important`' : `\`${f.value}\``;
47
- out.push(`- \`${f.file}:${f.line}\` — ${KIND_LABEL[f.kind]} ${f.kind === 'important' ? '' : val}`.trimEnd() + `. ${capitalise(f.advice)}.`);
51
+ out.push(`- \`${f.file}:${f.line}\` · ${KIND_LABEL[f.kind]} ${VALUELESS.has(f.kind) ? '' : `\`${f.value}\``}`.trimEnd() + `. ${capitalise(f.advice)}.`);
48
52
  }
49
53
  out.push('');
50
54
  out.push(`<sub>Only added lines are checked; the existing codebase is never judged. ${FOOTER}</sub>`);