guard-my-design-system 1.1.1 → 1.3.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 +61 -5
- package/index.mjs +15 -1
- package/package.json +2 -2
- package/src/judge.mjs +54 -2
- package/src/report.mjs +3 -0
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.
|
|
@@ -44,6 +49,22 @@ And what it deliberately ignores: everything that was already there. Even if
|
|
|
44
49
|
the codebase carries years of mess, the guard asks one question of a change:
|
|
45
50
|
does it make things worse?
|
|
46
51
|
|
|
52
|
+
## When the guard is wrong
|
|
53
|
+
|
|
54
|
+
Sometimes an off-system value is the right call: a partner's brand colour, a
|
|
55
|
+
gradient that needs its own stops. Say so on the line above, and the guard
|
|
56
|
+
lets that one line pass:
|
|
57
|
+
|
|
58
|
+
```css
|
|
59
|
+
/* guard-ignore-next-line — partner brand colour, agreed with design */
|
|
60
|
+
background: #e4002b;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Works in any file the guard reads (`//` in components, `/* */` in styles).
|
|
64
|
+
The exception is visible in code review by nature, silences exactly one line,
|
|
65
|
+
and keeps working on later pull requests that touch the same line. No config
|
|
66
|
+
file, no rule IDs — a sentence a reviewer can read is the whole mechanism.
|
|
67
|
+
|
|
47
68
|
## Why this exists
|
|
48
69
|
|
|
49
70
|
Nobody can win the argument "please go and clean up the codebase". Everyone can
|
|
@@ -85,6 +106,9 @@ Prefer a failed check over a comment? Strict mode is the one setting:
|
|
|
85
106
|
|
|
86
107
|
`exclude` keeps folders out of the system scan, same syntax as the CLI below.
|
|
87
108
|
|
|
109
|
+
Security-conscious teams can pin to an exact commit instead of a version tag:
|
|
110
|
+
`uses: gregkozakiewicz/guard-my-design-system@<commit-sha>`.
|
|
111
|
+
|
|
88
112
|
## On your machine
|
|
89
113
|
|
|
90
114
|
Judge your uncommitted work before anyone else sees it:
|
|
@@ -105,6 +129,38 @@ npx guard-my-design-system@latest
|
|
|
105
129
|
|
|
106
130
|
Requires Node 18+ and git.
|
|
107
131
|
|
|
132
|
+
## Not on GitHub?
|
|
133
|
+
|
|
134
|
+
The CLI works anywhere git works; only the comment-posting Action is
|
|
135
|
+
GitHub-specific. On GitLab, the merge request pipeline gets the failing check
|
|
136
|
+
with two lines in `.gitlab-ci.yml`:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
guard:
|
|
140
|
+
image: node:22
|
|
141
|
+
rules:
|
|
142
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
143
|
+
script:
|
|
144
|
+
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
|
145
|
+
- npx guard-my-design-system@latest --strict --base origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Bitbucket Pipelines, same idea in `bitbucket-pipelines.yml`:
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
pipelines:
|
|
152
|
+
pull-requests:
|
|
153
|
+
'**':
|
|
154
|
+
- step:
|
|
155
|
+
image: node:22
|
|
156
|
+
script:
|
|
157
|
+
- git fetch origin $BITBUCKET_PR_DESTINATION_BRANCH
|
|
158
|
+
- npx guard-my-design-system@latest --strict --base origin/$BITBUCKET_PR_DESTINATION_BRANCH
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The verdict prints in the pipeline log and `--strict` fails the step; the
|
|
162
|
+
sticky PR comment stays a GitHub luxury for now.
|
|
163
|
+
|
|
108
164
|
## What makes the verdict trustworthy
|
|
109
165
|
|
|
110
166
|
- **Only added lines are checked.** The existing codebase is never judged,
|
|
@@ -141,9 +197,9 @@ in a pull request:
|
|
|
141
197
|
workflow a read-only token). The guard still runs; the verdict lands in the
|
|
142
198
|
workflow log instead, with a line saying why. The same happens if the
|
|
143
199
|
workflow is missing `pull-requests: write`.
|
|
144
|
-
- **Not on GitHub?**
|
|
145
|
-
|
|
146
|
-
|
|
200
|
+
- **Not on GitHub?** Covered — copy-paste GitLab and Bitbucket recipes live in
|
|
201
|
+
[Not on GitHub?](#not-on-github) above. Only the comment-posting Action is
|
|
202
|
+
GitHub-specific.
|
|
147
203
|
|
|
148
204
|
## The family
|
|
149
205
|
|
package/index.mjs
CHANGED
|
@@ -71,7 +71,21 @@ 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
|
-
|
|
74
|
+
let findings = judge(judged, system);
|
|
75
|
+
|
|
76
|
+
// The escape hatch: a `guard-ignore-next-line` comment silences every finding
|
|
77
|
+
// on the line below it. Checked against the file as it stands (not just the
|
|
78
|
+
// diff), so an exception granted last month still protects its line today.
|
|
79
|
+
// Visible in code review by nature — that is the whole safety of it.
|
|
80
|
+
const fileCache = new Map();
|
|
81
|
+
const lineAbove = (file, line) => {
|
|
82
|
+
if (!fileCache.has(file)) {
|
|
83
|
+
try { fileCache.set(file, readFileSync(resolve(cwd, file), 'utf8').split('\n')); }
|
|
84
|
+
catch { fileCache.set(file, []); }
|
|
85
|
+
}
|
|
86
|
+
return fileCache.get(file)[line - 2] ?? '';
|
|
87
|
+
};
|
|
88
|
+
findings = findings.filter((f) => !lineAbove(f.file, f.line).includes('guard-ignore-next-line'));
|
|
75
89
|
|
|
76
90
|
if (flag('json')) {
|
|
77
91
|
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.
|
|
3
|
+
"version": "1.3.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.
|
|
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