guard-my-design-system 0.0.1 → 1.0.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 -11
- package/index.mjs +49 -8
- package/package.json +7 -3
- package/src/diff.mjs +57 -0
- package/src/judge.mjs +115 -0
- package/src/report.mjs +51 -0
package/README.md
CHANGED
|
@@ -6,30 +6,80 @@ Design systems don't die in a redesign. They die one pull request at a time:
|
|
|
6
6
|
a colour that's nearly the token, a spacing value that's off the scale, an
|
|
7
7
|
`!important` that seemed harmless on a Friday.
|
|
8
8
|
|
|
9
|
-
This is the smoke alarm at the door. It checks only the lines a pull request
|
|
10
|
-
adds
|
|
9
|
+
This is the smoke alarm at the door. It checks **only the lines a pull request
|
|
10
|
+
adds**, says nothing about the past, and points each new sin at the on-system
|
|
11
11
|
value the author probably meant. Old wiring is not its business; new sparks
|
|
12
12
|
are.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
It learns your system by scanning your repo with the
|
|
15
|
+
[roast-my-design-system](https://github.com/gregkozakiewicz/roast-my-design-system)
|
|
16
|
+
engine, then judges the diff against what it learned. No config files, no
|
|
17
|
+
rules to write, no tokens to register. Your codebase is the rulebook.
|
|
18
|
+
|
|
19
|
+
## What it catches
|
|
20
|
+
|
|
21
|
+
- a hard-coded colour where a token exists, with the nearest token named
|
|
22
|
+
- a spacing value the codebase has never used, with the nearest step named
|
|
23
|
+
- a typeface the system doesn't declare
|
|
24
|
+
- `!important`
|
|
25
|
+
- arbitrary Tailwind values (`w-[137px]`, `mt-[37px]`)
|
|
26
|
+
|
|
27
|
+
And what it deliberately ignores: everything that was already there. Even if
|
|
28
|
+
the codebase carries years of mess, the guard only asks one question of a
|
|
29
|
+
change: does it make things worse?
|
|
30
|
+
|
|
31
|
+
## On a pull request
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
# .github/workflows/guard.yml
|
|
35
|
+
name: guard
|
|
36
|
+
on: pull_request
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
pull-requests: write
|
|
40
|
+
jobs:
|
|
41
|
+
guard:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
with:
|
|
46
|
+
fetch-depth: 0
|
|
47
|
+
- uses: gregkozakiewicz/guard-my-design-system@v1
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every pull request gets one comment, updated in place, like:
|
|
51
|
+
|
|
52
|
+
> **🛡 guard-my-design-system: 2 new issues in this pull request**
|
|
53
|
+
>
|
|
54
|
+
> - `Card.tsx:24` — new colour `#4a7be8`. Nearest token: `#3b6fe0`.
|
|
55
|
+
> - `Card.tsx:31` — new spacing value `13px`. Nearest existing value: `12px`.
|
|
15
56
|
|
|
16
|
-
|
|
57
|
+
Prefer a failed check over a comment? Strict mode is the one setting:
|
|
17
58
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
59
|
+
```yaml
|
|
60
|
+
- uses: gregkozakiewicz/guard-my-design-system@v1
|
|
61
|
+
with:
|
|
62
|
+
strict: true
|
|
63
|
+
```
|
|
21
64
|
|
|
22
|
-
##
|
|
65
|
+
## On your machine
|
|
23
66
|
|
|
24
|
-
|
|
67
|
+
Judge your uncommitted work before anyone else sees it:
|
|
25
68
|
|
|
26
69
|
```bash
|
|
27
|
-
npx
|
|
70
|
+
npx guard-my-design-system
|
|
28
71
|
```
|
|
29
72
|
|
|
73
|
+
Flags: `--base <ref>` to diff against something other than main,
|
|
74
|
+
`--strict` to exit 1 on findings, `--json` for machines,
|
|
75
|
+
`--exclude <paths>` to leave folders out of the system scan.
|
|
76
|
+
|
|
77
|
+
## The family
|
|
78
|
+
|
|
30
79
|
[roast-my-design-system](https://github.com/gregkozakiewicz/roast-my-design-system)
|
|
31
80
|
diagnoses the whole codebase and scores it against a 34-repo benchmark.
|
|
32
|
-
|
|
81
|
+
guard keeps new work from adding to the pile. Roast diagnoses it, guard
|
|
82
|
+
protects it.
|
|
33
83
|
|
|
34
84
|
## Licence
|
|
35
85
|
|
package/index.mjs
CHANGED
|
@@ -1,14 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* guard-my-design-system — no new mess.
|
|
4
|
+
*
|
|
5
|
+
* Judges ONLY the lines a change adds against the design system the repo
|
|
6
|
+
* already has. Learns the system with the roast engine, reads the diff,
|
|
7
|
+
* says what strayed and what was probably meant instead.
|
|
8
|
+
*
|
|
9
|
+
* npx guard-my-design-system [path] [--base <ref>] [--strict] [--json]
|
|
10
|
+
* [--exclude <path>]
|
|
11
|
+
*
|
|
12
|
+
* Exit codes: 0 clean (or findings without --strict), 1 findings with
|
|
13
|
+
* --strict, 2 could not run.
|
|
14
|
+
*/
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import { learnSystem } from 'roast-my-design-system/engine';
|
|
17
|
+
import { defaultBase, addedLines } from './src/diff.mjs';
|
|
18
|
+
import { judge } from './src/judge.mjs';
|
|
19
|
+
import { terminalReport, markdownReport } from './src/report.mjs';
|
|
2
20
|
|
|
3
|
-
|
|
4
|
-
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
23
|
+
const opt = (name) => {
|
|
24
|
+
const i = argv.indexOf(`--${name}`);
|
|
25
|
+
return i > -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
26
|
+
};
|
|
5
27
|
|
|
6
|
-
|
|
7
|
-
|
|
28
|
+
const cwd = resolve(argv[0] && !argv[0].startsWith('--') ? argv[0] : '.');
|
|
29
|
+
const base = opt('base') ?? defaultBase(cwd);
|
|
30
|
+
if (!base) {
|
|
31
|
+
console.error('guard: cannot find a base to diff against (no origin/main, origin/master, main or master). Pass one with --base <ref>.');
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
8
34
|
|
|
9
|
-
|
|
35
|
+
let added;
|
|
36
|
+
try {
|
|
37
|
+
added = addedLines(cwd, base);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(`guard: git diff failed — ${e.message.split('\n')[0]}`);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
10
42
|
|
|
11
|
-
|
|
43
|
+
const exclude = opt('exclude') ? opt('exclude').split(',').map((s) => s.trim()) : [];
|
|
44
|
+
const system = learnSystem(cwd, { exclude });
|
|
45
|
+
const findings = judge(added, system);
|
|
12
46
|
|
|
13
|
-
|
|
14
|
-
|
|
47
|
+
if (flag('json')) {
|
|
48
|
+
console.log(JSON.stringify({ base, addedLines: added.length, findings }, null, 2));
|
|
49
|
+
} else if (flag('markdown')) {
|
|
50
|
+
console.log(markdownReport(findings));
|
|
51
|
+
} else {
|
|
52
|
+
console.log(terminalReport(findings));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.exit(findings.length && flag('strict') ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guard-my-design-system",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "No new mess. A pull request guard that
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "No new mess. A pull request guard that judges only the lines a change adds against the design system the repo already has, and names the on-system value the author probably meant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"guard-my-design-system": "index.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"index.mjs"
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"src/"
|
|
11
12
|
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"roast-my-design-system": "5.5.2"
|
|
15
|
+
},
|
|
12
16
|
"keywords": [
|
|
13
17
|
"design-system",
|
|
14
18
|
"design-tokens",
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff reading — the guard's field of vision. It sees ONLY the lines a change
|
|
3
|
+
* adds; the rest of the codebase is deliberately invisible. Old mess is not
|
|
4
|
+
* its business; new sparks are.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** The ref new work is measured against: merge-base with the main branch. */
|
|
11
|
+
export function defaultBase(cwd) {
|
|
12
|
+
for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', ['merge-base', 'HEAD', ref], { cwd, encoding: 'utf8' }).trim();
|
|
15
|
+
} catch { /* try the next spelling */ }
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Added lines between base and the working tree (committed or not), as
|
|
22
|
+
* [{ file, line, text }]. Unified diff with zero context so every '+' line
|
|
23
|
+
* is genuinely new.
|
|
24
|
+
*/
|
|
25
|
+
export function addedLines(cwd, base) {
|
|
26
|
+
const out = execFileSync('git', ['diff', '-U0', '--no-color', base, '--'], {
|
|
27
|
+
cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
const added = [];
|
|
30
|
+
let file = null, line = 0;
|
|
31
|
+
for (const raw of out.split('\n')) {
|
|
32
|
+
if (raw.startsWith('+++ ')) {
|
|
33
|
+
file = raw.slice(4).replace(/^b\//, '');
|
|
34
|
+
if (file === '/dev/null') file = null;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (raw.startsWith('@@')) {
|
|
38
|
+
const m = /\+(\d+)/.exec(raw);
|
|
39
|
+
line = m ? parseInt(m[1], 10) : 0;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!file) continue;
|
|
43
|
+
if (raw.startsWith('+')) { added.push({ file, line, text: raw.slice(1) }); line++; }
|
|
44
|
+
// '-' lines belong to the old world; context lines don't appear at -U0
|
|
45
|
+
}
|
|
46
|
+
// A brand-new file not yet staged never appears in git diff; every one of
|
|
47
|
+
// its lines is added work all the same.
|
|
48
|
+
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
49
|
+
cwd, encoding: 'utf8',
|
|
50
|
+
}).split('\n').filter(Boolean);
|
|
51
|
+
for (const f of untracked) {
|
|
52
|
+
let text;
|
|
53
|
+
try { text = readFileSync(join(cwd, f), 'utf8'); } catch { continue; }
|
|
54
|
+
text.split('\n').forEach((t, i) => { if (t.trim()) added.push({ file: f, line: i + 1, text: t }); });
|
|
55
|
+
}
|
|
56
|
+
return added;
|
|
57
|
+
}
|
package/src/judge.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge — added lines on one side, the learned system on the other.
|
|
3
|
+
* All detection comes from the roast engine's official doorway; this file
|
|
4
|
+
* only decides what counts as "new mess" in the context of a diff.
|
|
5
|
+
*
|
|
6
|
+
* The rule for every sin: it must be NEW. A colour that is already a token,
|
|
7
|
+
* a length the codebase already uses, a typeface the system already declares
|
|
8
|
+
* — all invisible. The guard never asks anyone to clean the past.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
extractStyling, normalizeHex, nearestColor, nearestLength,
|
|
12
|
+
isCodeFile, isStyleFile, typefaceOf, GENERIC_FONTS,
|
|
13
|
+
} from 'roast-my-design-system/engine';
|
|
14
|
+
|
|
15
|
+
// Same honesty exemptions the engine applies: email and print styling must be
|
|
16
|
+
// inline, and artwork files carry hex that is drawing, not styling.
|
|
17
|
+
const EXEMPT_FILE_RE = /email|(^|[/.])print([/.]|$)|(^|\/)[\w.-]*(icon|logo|badge|illustration|artwork)[\w.-]*\.(tsx|jsx)$/i;
|
|
18
|
+
|
|
19
|
+
const FONT_LINE_RE = /font-family\s*:\s*([^;{}]+)/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Judge added lines against the learned system.
|
|
23
|
+
* Returns [{ file, line, kind, value, advice }] sorted by file then line.
|
|
24
|
+
* kinds: color | spacing | arbitrary | important | font
|
|
25
|
+
*/
|
|
26
|
+
export function judge(added, system) {
|
|
27
|
+
const tokenSet = new Set(system.tokens);
|
|
28
|
+
|
|
29
|
+
// The system was learned from the tree that already CONTAINS these added
|
|
30
|
+
// lines, so a new value would vouch for itself. A value is only "known"
|
|
31
|
+
// if the repo uses it more times than this change added it.
|
|
32
|
+
const addedLengths = new Map(), addedFaces = new Map();
|
|
33
|
+
for (const { file, line, text } of added) {
|
|
34
|
+
if (EXEMPT_FILE_RE.test(file)) continue;
|
|
35
|
+
const css = isStyleFile(file);
|
|
36
|
+
if (!css && !isCodeFile(file)) continue;
|
|
37
|
+
for (const s of extractStyling(text, { css }).spacing) {
|
|
38
|
+
addedLengths.set(s.value, (addedLengths.get(s.value) ?? 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
const fm = css ? FONT_LINE_RE.exec(text) : null;
|
|
41
|
+
const face = fm ? typefaceOf(fm[1].trim()) : null;
|
|
42
|
+
if (face) addedFaces.set(face, (addedFaces.get(face) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
const knownLengths = new Set(
|
|
45
|
+
system.spacing.filter((s) => s.count > (addedLengths.get(s.value) ?? 0)).map((s) => s.value)
|
|
46
|
+
);
|
|
47
|
+
const faceCounts = new Map();
|
|
48
|
+
for (const f of system.fontFamilies) {
|
|
49
|
+
const face = typefaceOf(f.value);
|
|
50
|
+
if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
|
|
51
|
+
}
|
|
52
|
+
const knownFaces = new Set(
|
|
53
|
+
[...faceCounts].filter(([face, n]) => n > (addedFaces.get(face) ?? 0)).map(([face]) => face)
|
|
54
|
+
);
|
|
55
|
+
const findings = [];
|
|
56
|
+
|
|
57
|
+
for (const { file, line, text } of added) {
|
|
58
|
+
if (EXEMPT_FILE_RE.test(file)) continue;
|
|
59
|
+
const css = isStyleFile(file);
|
|
60
|
+
if (!css && !isCodeFile(file)) continue;
|
|
61
|
+
|
|
62
|
+
const seen = extractStyling(text, { css });
|
|
63
|
+
|
|
64
|
+
for (const c of seen.colors) {
|
|
65
|
+
if (tokenSet.has(c.value)) continue; // disciplined token use
|
|
66
|
+
const near = c.value.startsWith('#') ? nearestColor(c.value, system.tokens) : null;
|
|
67
|
+
findings.push({
|
|
68
|
+
file, line, kind: 'color', value: c.value,
|
|
69
|
+
advice: near && near.distance <= 48
|
|
70
|
+
? `nearest token: ${near.value}`
|
|
71
|
+
: system.tokenFile
|
|
72
|
+
? `no token resembles it — if it is a real decision, it belongs in ${system.tokenFile}`
|
|
73
|
+
: 'no token layer found to compare against',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const s of seen.spacing) {
|
|
78
|
+
if (knownLengths.has(s.value)) continue; // the codebase already uses it
|
|
79
|
+
const near = nearestLength(s.value, [...knownLengths]);
|
|
80
|
+
findings.push({
|
|
81
|
+
file, line, kind: 'spacing', value: s.value,
|
|
82
|
+
advice: near ? `nearest existing value: ${near.value}` : 'first value of its unit in this codebase',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const a of seen.arbitrary) {
|
|
87
|
+
findings.push({
|
|
88
|
+
file, line, kind: 'arbitrary', value: a.value,
|
|
89
|
+
advice: 'an arbitrary Tailwind value sidesteps the scale; use a scale step or add one',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const _ of seen.important) {
|
|
94
|
+
findings.push({
|
|
95
|
+
file, line, kind: 'important', value: '!important',
|
|
96
|
+
advice: 'the cascade admitting defeat; raise specificity or fix the source order',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fm = css ? FONT_LINE_RE.exec(text) : null;
|
|
101
|
+
if (fm && !/^(var\(--[\w-]+\)|inherit)$/i.test(fm[1].trim())) {
|
|
102
|
+
const face = typefaceOf(fm[1].trim());
|
|
103
|
+
if (face && !GENERIC_FONTS.has(face.toLowerCase()) && !knownFaces.has(face)) {
|
|
104
|
+
findings.push({
|
|
105
|
+
file, line, kind: 'font', value: face,
|
|
106
|
+
advice: knownFaces.size
|
|
107
|
+
? `the system declares: ${[...knownFaces].join(', ')}`
|
|
108
|
+
: 'first typeface declared in this codebase',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
115
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The voice — findings are blunt, advice is calm and starts from intent.
|
|
3
|
+
* One format for the terminal, one for the PR comment (markdown). Both end
|
|
4
|
+
* by pointing at the inspector for the full picture.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const KIND_LABEL = {
|
|
8
|
+
color: 'new colour',
|
|
9
|
+
spacing: 'new spacing value',
|
|
10
|
+
arbitrary: 'arbitrary Tailwind value',
|
|
11
|
+
important: '!important',
|
|
12
|
+
font: 'new typeface',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const FOOTER = 'Full picture of the whole codebase: `npx roast-my-design-system`';
|
|
16
|
+
|
|
17
|
+
export function terminalReport(findings) {
|
|
18
|
+
if (!findings.length) {
|
|
19
|
+
return 'guard-my-design-system: no new mess. Nothing added in this change strays from the system.';
|
|
20
|
+
}
|
|
21
|
+
const lines = [`guard-my-design-system: ${findings.length} new issue${findings.length === 1 ? '' : 's'} in this change\n`];
|
|
22
|
+
for (const f of findings) {
|
|
23
|
+
lines.push(` ${f.file}:${f.line} — ${KIND_LABEL[f.kind]} ${f.value === '!important' ? '' : f.value}`.trimEnd() + `. ${capitalise(f.advice)}.`);
|
|
24
|
+
}
|
|
25
|
+
lines.push('');
|
|
26
|
+
lines.push(' Only lines added in this change were counted. The existing codebase was not judged.');
|
|
27
|
+
lines.push(` ${FOOTER.replaceAll('`', '')}`);
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function markdownReport(findings) {
|
|
32
|
+
if (!findings.length) {
|
|
33
|
+
return [
|
|
34
|
+
'**🛡 guard-my-design-system: no new mess**',
|
|
35
|
+
'',
|
|
36
|
+
'Nothing added in this pull request strays from the design system.',
|
|
37
|
+
'',
|
|
38
|
+
`<sub>Only added lines are checked; the existing codebase is never judged. ${FOOTER}</sub>`,
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
const out = [`**🛡 guard-my-design-system: ${findings.length} new issue${findings.length === 1 ? '' : 's'} in this pull request**`, ''];
|
|
42
|
+
for (const f of findings) {
|
|
43
|
+
const val = f.kind === 'important' ? '`!important`' : `\`${f.value}\``;
|
|
44
|
+
out.push(`- \`${f.file}:${f.line}\` — ${KIND_LABEL[f.kind]} ${f.kind === 'important' ? '' : val}`.trimEnd() + `. ${capitalise(f.advice)}.`);
|
|
45
|
+
}
|
|
46
|
+
out.push('');
|
|
47
|
+
out.push(`<sub>Only added lines are checked; the existing codebase is never judged. ${FOOTER}</sub>`);
|
|
48
|
+
return out.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|