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 +25 -10
- package/index.mjs +12 -2
- package/package.json +2 -2
- package/src/judge.mjs +87 -36
- package/src/report.mjs +7 -3
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`
|
|
11
|
+
> `Card.tsx:24` · new colour `#4a7be8`. Nearest token: `var(--blue-500)`, `#3b6fe0`.
|
|
12
12
|
>
|
|
13
|
-
> `site.css:31`
|
|
13
|
+
> `site.css:31` · new spacing value `13px`. Nearest existing value: `12px`.
|
|
14
14
|
>
|
|
15
|
-
> `site.css:32`
|
|
15
|
+
> `site.css:32` · new border radius `5px`. Nearest existing value: `6px`.
|
|
16
16
|
>
|
|
17
|
-
> `site.css:33`
|
|
17
|
+
> `site.css:33` · new typeface `Comic Sans MS`. First typeface declared in this codebase.
|
|
18
18
|
>
|
|
19
|
-
> `site.css:35`
|
|
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,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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>`);
|