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 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, says nothing about the past, and points each new sin at the on-system
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
- ## Status
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
- Not ready yet. This publish reserves the name while v1 is built.
57
+ Prefer a failed check over a comment? Strict mode is the one setting:
17
58
 
18
- When it ships, setup will be five minutes: one devDependency and a copy-paste
19
- GitHub Action. After that you forget it exists, which is the whole point of a
20
- smoke alarm.
59
+ ```yaml
60
+ - uses: gregkozakiewicz/guard-my-design-system@v1
61
+ with:
62
+ strict: true
63
+ ```
21
64
 
22
- ## In the meantime
65
+ ## On your machine
23
66
 
24
- Find out how bad things already are:
67
+ Judge your uncommitted work before anyone else sees it:
25
68
 
26
69
  ```bash
27
- npx roast-my-design-system
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
- roast diagnoses it, guard protects it.
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
- console.log(`
4
- guard-my-design-system — no new mess.
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
- A pull request guard that stops design system drift one diff at a time.
7
- It is not ready yet. This publish reserves the name while it is built.
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
- In the meantime, find out how bad things already are:
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
- npx roast-my-design-system
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
- Follow along: https://github.com/gregkozakiewicz/guard-my-design-system
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.1",
4
- "description": "No new mess. A pull request guard that stops design system drift one diff at a time. Coming soon.",
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);