guard-my-design-system 1.1.0 → 1.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
@@ -10,10 +10,12 @@ probably meant. Old wiring is not its business; new sparks are.
10
10
 
11
11
  It speaks in nearests, not scoldings:
12
12
 
13
- > `Card.tsx:24` — new colour `#4a7be8`. Nearest token: `#3b6fe0`.
13
+ > `Card.tsx:24` — new colour `#4a7be8`. Nearest token: `var(--blue-500)`, `#3b6fe0`.
14
14
  >
15
15
  > `site.css:31` — new spacing value `13px`. Nearest existing value: `12px`.
16
16
  >
17
+ > `site.css:32` — new border radius `5px`. Nearest existing value: `6px`.
18
+ >
17
19
  > `site.css:33` — new typeface `Comic Sans MS`. First typeface declared in this codebase.
18
20
  >
19
21
  > `site.css:35` — `!important`. The cascade admitting defeat; raise specificity or fix the source order.
@@ -34,8 +36,11 @@ a fix and the same comment counts down instead of piling up:
34
36
 
35
37
  ## What it catches
36
38
 
37
- - **A hard-coded colour where a token exists**, with the nearest token named.
39
+ - **A hard-coded colour where a token exists**, with the nearest token named by
40
+ its variable: `var(--blue-500)`, not a hex to go hunting for.
38
41
  - **A spacing value the codebase has never used**, with the nearest step named.
42
+ - **A border radius, font size or shadow** the system does not declare, with
43
+ the nearest existing value named.
39
44
  - **A typeface the system does not declare.**
40
45
  - **`!important`**, the cascade admitting defeat.
41
46
  - **Arbitrary Tailwind values** (`w-[137px]`, `mt-[37px]`) that sidestep the scale.
@@ -85,6 +90,9 @@ Prefer a failed check over a comment? Strict mode is the one setting:
85
90
 
86
91
  `exclude` keeps folders out of the system scan, same syntax as the CLI below.
87
92
 
93
+ Security-conscious teams can pin to an exact commit instead of a version tag:
94
+ `uses: gregkozakiewicz/guard-my-design-system@<commit-sha>`.
95
+
88
96
  ## On your machine
89
97
 
90
98
  Judge your uncommitted work before anyone else sees it:
@@ -123,6 +131,28 @@ Requires Node 18+ and git.
123
131
  on-system value the author probably meant, so the fix takes thirty seconds
124
132
  and no meeting.
125
133
 
134
+ ## Honest limits
135
+
136
+ Things the guard deliberately does not do, said here so they never surprise you
137
+ in a pull request:
138
+
139
+ - **Monorepos are judged as one world.** The system is learned from the whole
140
+ repo, so a colour that is legitimate in `packages/ui` counts as known when it
141
+ appears in `apps/web`. Per-package judgement is roast's territory today.
142
+ - **Spacing is compared within one unit.** A repo on a rem scale that receives
143
+ `13px` gets the flag, but "nearest existing value" never converts units:
144
+ claiming `0.75rem` is nearest to `13px` would be a judgement faked, not made.
145
+ - **Taste is not judged, and tokens are a passport.** The right token in the
146
+ wrong place sails through, and defining a new token is never a sin. The
147
+ guard polices drift, not decisions: extending the system is legitimate work.
148
+ - **On a fork's pull request, the comment cannot be posted** (GitHub hands the
149
+ workflow a read-only token). The guard still runs; the verdict lands in the
150
+ workflow log instead, with a line saying why. The same happens if the
151
+ workflow is missing `pull-requests: write`.
152
+ - **Not on GitHub?** The CLI works anywhere git works: GitLab and Bitbucket CI
153
+ can run `npx guard-my-design-system --strict --base <target branch sha>` and
154
+ get the failing check. Only the comment-posting Action is GitHub-specific.
155
+
126
156
  ## The family
127
157
 
128
158
  [roast-my-design-system](https://github.com/gregkozakiewicz/roast-my-design-system)
package/index.mjs CHANGED
@@ -58,8 +58,20 @@ try {
58
58
  }
59
59
 
60
60
  const exclude = opt('exclude') ? opt('exclude').split(',').map((s) => s.trim()) : [];
61
+
62
+ // Excluded folders are invisible to the whole tool: not learned from, and not
63
+ // judged either. Same sources as the scan (--exclude and .roastignore), same
64
+ // plain folder prefixes, nothing clever.
65
+ let ignorePrefixes = [...exclude];
66
+ try {
67
+ ignorePrefixes.push(...readFileSync(resolve(cwd, '.roastignore'), 'utf8')
68
+ .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')));
69
+ } catch { /* no .roastignore, nothing to add */ }
70
+ ignorePrefixes = ignorePrefixes.map((p) => p.replace(/^\.?\//, '').replace(/\/?$/, '/'));
71
+ const judged = added.filter(({ file }) => !ignorePrefixes.some((p) => (file + '/').startsWith(p)));
72
+
61
73
  const system = learnSystem(cwd, { exclude });
62
- const findings = judge(added, system);
74
+ const findings = judge(judged, system);
63
75
 
64
76
  if (flag('json')) {
65
77
  console.log(JSON.stringify({ base, addedLines: added.length, findings }, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guard-my-design-system",
3
- "version": "1.1.0",
3
+ "version": "1.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": "5.5.2"
14
+ "roast-my-design-system": "5.5.3"
15
15
  },
16
16
  "keywords": [
17
17
  "design-system",
package/src/judge.mjs CHANGED
@@ -30,6 +30,23 @@ function exemptFiles(added) {
30
30
 
31
31
  const FONT_LINE_RE = /font-family\s*:\s*([^;{}]+)/i;
32
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
+ };
49
+
33
50
  /**
34
51
  * Judge added lines against the learned system.
35
52
  * Returns [{ file, line, kind, value, advice }] sorted by file then line.
@@ -43,6 +60,7 @@ export function judge(added, system) {
43
60
  // if the repo uses it more times than this change added it.
44
61
  const exempt = exemptFiles(added);
45
62
  const addedLengths = new Map(), addedFaces = new Map();
63
+ const addedExtras = { radius: new Map(), fontsize: new Map(), shadow: new Map() };
46
64
  for (const { file, line, text } of added) {
47
65
  if (exempt(file)) continue;
48
66
  const css = isStyleFile(file);
@@ -50,6 +68,12 @@ export function judge(added, system) {
50
68
  for (const s of extractStyling(text, { css }).spacing) {
51
69
  addedLengths.set(s.value, (addedLengths.get(s.value) ?? 0) + 1);
52
70
  }
71
+ if (css) {
72
+ for (const { kind, re } of EXTRA_KINDS) {
73
+ const v = extraValue(re, text);
74
+ if (v) addedExtras[kind].set(v, (addedExtras[kind].get(v) ?? 0) + 1);
75
+ }
76
+ }
53
77
  const fm = css ? FONT_LINE_RE.exec(text) : null;
54
78
  const face = fm ? typefaceOf(fm[1].trim()) : null;
55
79
  if (face) addedFaces.set(face, (addedFaces.get(face) ?? 0) + 1);
@@ -57,6 +81,18 @@ export function judge(added, system) {
57
81
  const knownLengths = new Set(
58
82
  system.spacing.filter((s) => s.count > (addedLengths.get(s.value) ?? 0)).map((s) => s.value)
59
83
  );
84
+ const knownExtras = {};
85
+ for (const { kind, learned } of EXTRA_KINDS) {
86
+ knownExtras[kind] = new Set(
87
+ (system[learned] ?? []).filter((e) => e.count > (addedExtras[kind].get(e.value) ?? 0)).map((e) => e.value)
88
+ );
89
+ }
90
+ // "use var(--blue-500)", not "go hunt this hex": name a value when the
91
+ // system defines it as a custom property.
92
+ const named = (value) => {
93
+ const n = system.tokenNames?.[value];
94
+ return n ? `var(${n}), ${value}` : value;
95
+ };
60
96
  const faceCounts = new Map();
61
97
  for (const f of system.fontFamilies) {
62
98
  const face = typefaceOf(f.value);
@@ -80,7 +116,7 @@ export function judge(added, system) {
80
116
  findings.push({
81
117
  file, line, kind: 'color', value: c.value,
82
118
  advice: near && near.distance <= 48
83
- ? `nearest token: ${near.value}`
119
+ ? `nearest token: ${named(near.value)}`
84
120
  : system.tokenFile
85
121
  ? `no token resembles it — if it is a real decision, it belongs in ${system.tokenFile}`
86
122
  : 'no token layer found to compare against',
@@ -92,10 +128,26 @@ export function judge(added, system) {
92
128
  const near = nearestLength(s.value, [...knownLengths]);
93
129
  findings.push({
94
130
  file, line, kind: 'spacing', value: s.value,
95
- advice: near ? `nearest existing value: ${near.value}` : 'first value of its unit in this codebase',
131
+ advice: near ? `nearest existing value: ${named(near.value)}` : 'first value of its unit in this codebase',
96
132
  });
97
133
  }
98
134
 
135
+ if (css) {
136
+ for (const { kind, re } of EXTRA_KINDS) {
137
+ const v = extraValue(re, text);
138
+ if (!v || knownExtras[kind].has(v)) continue;
139
+ const near = nearestLength(v, [...knownExtras[kind]]);
140
+ findings.push({
141
+ file, line, kind, value: v,
142
+ advice: near
143
+ ? `nearest existing value: ${named(near.value)}`
144
+ : knownExtras[kind].size
145
+ ? `differs from every one the system declares`
146
+ : 'first of its kind in this codebase',
147
+ });
148
+ }
149
+ }
150
+
99
151
  for (const a of seen.arbitrary) {
100
152
  findings.push({
101
153
  file, line, kind: 'arbitrary', value: a.value,
package/src/report.mjs CHANGED
@@ -7,6 +7,9 @@
7
7
  const KIND_LABEL = {
8
8
  color: 'new colour',
9
9
  spacing: 'new spacing value',
10
+ radius: 'new border radius',
11
+ fontsize: 'new font size',
12
+ shadow: 'new shadow',
10
13
  arbitrary: 'arbitrary Tailwind value',
11
14
  important: '!important',
12
15
  font: 'new typeface',