guard-my-design-system 2.0.0 → 2.2.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
@@ -40,7 +40,9 @@ it updates that same comment. It never adds more comments:
40
40
  ## What it catches
41
41
 
42
42
  - **A hard-coded colour where a token exists.** The finding names the token:
43
- `var(--blue-500)`, not just a hex code. This works across colour notations:
43
+ `var(--blue-500)`, not just a hex code. A token's own value pasted into a
44
+ component or a stylesheet counts too: the finding says use the name, not
45
+ the value. This works across colour notations:
44
46
  a hex stray is matched to an hsl or oklch token, including shadcn's
45
47
  bare-triplet variables. Dark-theme values count as the system too, so a
46
48
  stray in a dark block snaps to the dark token, never its light twin.
@@ -54,8 +56,17 @@ it updates that same comment. It never adds more comments:
54
56
  - **An inline `style={{ }}` block.** Styling written there is invisible to the
55
57
  system and to every agent that reads the file. Blocks built from variables
56
58
  are decided elsewhere, so they are left alone.
59
+ - **A button built from scratch where the repo already has a Button.** A
60
+ styled button, or a button tag dressed as one, in a file whose package
61
+ can import a Button that at least 20 files already use. The finding
62
+ gives the import line. Rows, tabs, close crosses and select triggers
63
+ built on a button tag are left alone.
57
64
  - **A second definition of a component you already have.** The finding names
58
- the file that already defines it, and how many places use that one.
65
+ the file that already defines it, and how many places use that one. Web
66
+ components registered by tag count too. What a copy is, is the roast
67
+ report's answer: a framework's `Route` or `Layout`, a page, a story, an
68
+ email template and a wrapper built on the component it shares a name with
69
+ are not second copies.
59
70
  - **A new import of a duplicate component.** When a name is defined in more
60
71
  than one file and one copy is clearly the main one, importing another copy
61
72
  is flagged. The finding names the main copy, how often each is used, and
@@ -232,8 +243,9 @@ updating PR comment works on GitHub only, for now.
232
243
  picture drawn with code. A canvas renderer draws pixels. A file that draws
233
244
  SVG is artwork, not interface. The guard reads that list from the roast
234
245
  engine rather than keeping its own, so the two can never drift apart and
235
- give you different answers about the same file. Defining a new token is
236
- extending the system, not a problem.
246
+ give you different answers about the same file. The exemptions are about
247
+ styling: a second `Logo` is still a second `Logo`, and is flagged. Defining
248
+ a new token is extending the system, not a problem.
237
249
  - **Every finding comes with a fix.** The guard names the on-system value the
238
250
  author probably meant, so most fixes take under a minute and no meeting.
239
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guard-my-design-system",
3
- "version": "2.0.0",
3
+ "version": "2.2.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": "9.0.0"
14
+ "roast-my-design-system": "9.2.1"
15
15
  },
16
16
  "keywords": [
17
17
  "design-system",
package/src/judge.mjs CHANGED
@@ -10,10 +10,10 @@
10
10
  import {
11
11
  extractStyling, normalizeHex, nearestColor, nearestLength,
12
12
  isCodeFile, isStyleFile, typefaceOf, GENERIC_FONTS,
13
- definedComponents, exemptReason,
13
+ definedComponents, componentNamesIn, duplicateCopies, isPageFile, exemptReason,
14
14
  EXTRA_KINDS, extraValue, fontDeclarations,
15
15
  WIDGET_CSS_RE, isLibraryClass, PALETTE_CLASS_RE, blankComments, kitPaintFindings,
16
- tokenTwinFindings, avoidedImportFindings, isChartFile, chartFindings,
16
+ tokenTwinFindings, avoidedImportFindings, isChartFile, chartFindings, handmadeButtonFindings,
17
17
  } from 'roast-my-design-system/engine';
18
18
 
19
19
  // Folder membership, the way the engine's own splits do it.
@@ -163,6 +163,9 @@ export function judge(added, system, { readFile, readBase } = {}) {
163
163
  }
164
164
  // "use var(--blue-500)", not "go hunt this hex": name a value when the
165
165
  // system defines it as a custom property.
166
+ const statesTokens = (file) => (system.tokenSources ?? []).some((t) => samePath(t, file));
167
+ // on a kit repo the theme is where a colour is decided (mcp/knowledge reads it the same way)
168
+ const themeFile = system.profile?.kit?.themeFiles?.[0] ?? system.tokenFile ?? null;
166
169
  const named = (value) => {
167
170
  // shadcn-style tokens are defined as bare triplets (--primary: 222.2 47.4%
168
171
  // 11.2%) but normalised to hsl(...); try the unwrapped form too.
@@ -178,12 +181,13 @@ export function judge(added, system, { readFile, readBase } = {}) {
178
181
  const knownFaces = new Set(
179
182
  [...faceCounts].filter(([face, n]) => n > (addedFaces.get(face) ?? 0)).map(([face]) => face)
180
183
  );
181
- // Components the repo already defines, by name. Pages are routes rather than
182
- // reusable parts, so two of a name there is not a second Button.
184
+ // Components the repo already defines, by name: the whole ledger. What
185
+ // counts as a second copy is the engine's call (duplicateCopies), so the
186
+ // guard and the report give one answer. A page, a framework's Route, a
187
+ // story, a wrapper and two icon libraries colliding are not second copies.
183
188
  const componentsByName = new Map();
184
189
  if (definedComponents && Array.isArray(system.components)) {
185
190
  for (const c of system.components) {
186
- if (c.isPage) continue;
187
191
  const list = componentsByName.get(c.name) ?? [];
188
192
  list.push(c);
189
193
  componentsByName.set(c.name, list);
@@ -233,10 +237,54 @@ export function judge(added, system, { readFile, readBase } = {}) {
233
237
  return isChartFile(file, w ?? '');
234
238
  };
235
239
 
240
+ // A hand-rolled second <Button> is the most expensive thing a pull request
241
+ // can add, and it was the one thing the guard could not see. The scan
242
+ // includes this change, so the new copy is in the ledger too: what counts
243
+ // is whether the name lives anywhere ELSE, and the engine answers that the
244
+ // way the report does (duplicateCopies).
245
+ const definedByFile = new Map();
246
+ const definedIn = (file) => {
247
+ if (!definedByFile.has(file)) definedByFile.set(file, new Set(componentNamesIn(wholeText(file) ?? '', file)));
248
+ return definedByFile.get(file);
249
+ };
250
+ const secondCopies = (file, line, text) => {
251
+ const out = [];
252
+ if (!componentsByName.size) return out;
253
+ // what the whole file defines for the report, kept to what this line declares
254
+ const onLine = new Set(definedComponents(text));
255
+ const names = wholeText(file) === null
256
+ ? [...onLine]
257
+ : [...definedIn(file)].filter((n) => onLine.has(n) || new RegExp(`\\bclass\\s+${n}\\b`).test(text));
258
+ for (const name of names) {
259
+ const variantOf = (f) => variants.find((v) => underAny(f, [v])) ?? null;
260
+ const counted = system.duplicates?.get?.(name)?.copies.map((c) => c.file) ?? null;
261
+ const elsewhere = duplicateCopies({ name, file, isPage: isPageFile(file) }, componentsByName.get(name), counted, samePath)
262
+ // a registry keeps the same component in sibling variants, and a
263
+ // block installs alone: neither is a second Button
264
+ .filter((c) => !(variantOf(file) && variantOf(c.file) && variantOf(c.file) !== variantOf(file)))
265
+ .filter((c) => !(underAny(file, blockDirs) && underAny(c.file, blockDirs)));
266
+ if (!elsewhere.length) continue;
267
+ const best = [...elsewhere].sort((a, b) => b.usageCount - a.usageCount)[0];
268
+ out.push({
269
+ file, line, kind: 'component', value: name,
270
+ // never open the advice with the path: the report capitalises the
271
+ // first letter, and a capitalised path is the wrong path
272
+ advice: elsewhere.length > 1
273
+ ? `${elsewhere.length} other files define it too; import ${best.file}, the one the codebase leans on`
274
+ : `import ${best.file} rather than starting a second one${best.usageCount ? `, which ${best.usageCount} place${best.usageCount === 1 ? '' : 's'} already do` : ''}`,
275
+ });
276
+ }
277
+ return out;
278
+ };
279
+
236
280
  for (const { file, line, text } of added) {
237
- if (exempt(file) || outOfScope(file)) continue;
281
+ if (outOfScope(file)) continue;
238
282
  const css = isStyleFile(file);
239
283
  if (!css && !isCodeFile(file)) continue;
284
+ // The exemptions are about styling: what an email, a drawing or a crash
285
+ // page cannot take from the system. A second copy of a component is a
286
+ // second copy in any medium, and the report counts it.
287
+ if (exempt(file)) { if (!css) findings.push(...secondCopies(file, line, text)); continue; }
240
288
 
241
289
  const seen = extractStyling(text, { css });
242
290
 
@@ -259,7 +307,21 @@ export function judge(added, system, { readFile, readBase } = {}) {
259
307
 
260
308
  for (const c of seen.colors) {
261
309
  if (onKit || chart) break; // the kit rule or the chart rule owns colours here
262
- if (tokenSet.has(c.value)) continue; // disciplined token use
310
+ if (tokenSet.has(c.value)) {
311
+ // A token's raw value is the definition only inside a file that
312
+ // states the palette (system.tokenSources, roast 9.1.3). Anywhere
313
+ // else it is the value pasted where the name belongs: the system
314
+ // cannot see it, and the next reader copies the hex.
315
+ if (!system.tokenSources || statesTokens(file)) continue;
316
+ const n = named(c.value);
317
+ findings.push({
318
+ file, line, kind: 'color', value: c.value,
319
+ advice: n !== c.value
320
+ ? `this is already the token ${n}; use the name, not the value`
321
+ : `the theme already holds this value${themeFile ? ` (${themeFile})` : ''}; read it from there rather than pasting it`,
322
+ });
323
+ continue;
324
+ }
263
325
  const near = c.value.startsWith('#') ? nearestColor(c.value, system.tokens) : null;
264
326
  findings.push({
265
327
  file, line, kind: 'color', value: c.value,
@@ -308,30 +370,7 @@ export function judge(added, system, { readFile, readBase } = {}) {
308
370
  });
309
371
  }
310
372
 
311
- // A hand-rolled second <Button> is the most expensive thing a pull request
312
- // can add, and it was the one thing the guard could not see. The scan
313
- // includes this change, so the new copy is in the ledger too: what counts
314
- // is whether the name lives anywhere ELSE.
315
- if (!css && componentsByName.size) {
316
- for (const name of definedComponents(text)) {
317
- const variantOf = (f) => variants.find((v) => underAny(f, [v])) ?? null;
318
- const elsewhere = (componentsByName.get(name) ?? []).filter((c) => !samePath(c.file, file))
319
- // a registry keeps the same component in sibling variants, and a
320
- // block installs alone: neither is a second Button
321
- .filter((c) => !(variantOf(file) && variantOf(c.file) && variantOf(c.file) !== variantOf(file)))
322
- .filter((c) => !(underAny(file, blockDirs) && underAny(c.file, blockDirs)));
323
- if (!elsewhere.length) continue;
324
- const best = [...elsewhere].sort((a, b) => b.usageCount - a.usageCount)[0];
325
- findings.push({
326
- file, line, kind: 'component', value: name,
327
- // never open the advice with the path: the report capitalises the
328
- // first letter, and a capitalised path is the wrong path
329
- advice: elsewhere.length > 1
330
- ? `${elsewhere.length} other files define it too; import ${best.file}, the one the codebase leans on`
331
- : `import ${best.file} rather than starting a second one${best.usageCount ? `, which ${best.usageCount} place${best.usageCount === 1 ? '' : 's'} already do` : ''}`,
332
- });
333
- }
334
- }
373
+ if (!css) findings.push(...secondCopies(file, line, text));
335
374
 
336
375
  for (const a of seen.arbitrary) {
337
376
  findings.push({
@@ -414,7 +453,13 @@ export function judge(added, system, { readFile, readBase } = {}) {
414
453
  others: system.tokenDefs.filter((d) => !samePath(d.file, file)),
415
454
  tailwind: prof.kind === 'tailwind' || /@theme\b/.test(w),
416
455
  }) : [])
417
- : (system.duplicates ? avoidedImportFindings(w, { file, before: baseText(file), dupes: system.duplicates }) : []);
456
+ : [
457
+ ...(system.duplicates ? avoidedImportFindings(w, { file, before: baseText(file), dupes: system.duplicates }) : []),
458
+ // a button built from scratch where the file's own package can
459
+ // import the repo's Button (roast 9.2.0); a warning in the engine,
460
+ // a finding here, on the line the button starts
461
+ ...(system.buttons?.length ? handmadeButtonFindings(w, { file }, system) : []),
462
+ ];
418
463
  for (const f of hits) {
419
464
  const line = lineAt(w, f.index);
420
465
  if (!lines.has(line)) continue;
package/src/report.mjs CHANGED
@@ -26,11 +26,14 @@ const KIND_LABEL = {
26
26
  // painting by hand where the repo keeps none
27
27
  'chart-colour': 'chart colour written by hand',
28
28
  'chart-palette': 'chart painted by hand',
29
+ // a styled.button or a dressed button tag where the repo has a Button
30
+ // (roast 9.2.0); the engine's sentence names the Button and its import
31
+ 'handmade-button': 'button built from scratch',
29
32
  };
30
33
  const labelOf = (f) => f.label ?? KIND_LABEL[f.kind];
31
34
 
32
35
  // Kinds whose label already says everything; printing the value repeats it.
33
- const VALUELESS = new Set(['important', 'inline', 'twin-token', 'avoided-copy', 'chart-palette']);
36
+ const VALUELESS = new Set(['important', 'inline', 'twin-token', 'avoided-copy', 'chart-palette', 'handmade-button']);
34
37
 
35
38
  const FOOTER = 'Full picture of the whole codebase: `npx roast-my-design-system`';
36
39