roast-my-design-system 8.6.2 → 8.8.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 +6 -3
- package/cli/roast.mjs +16 -0
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/lib/charts.mjs +146 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +5 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +15 -2
- package/skills/roast-my-design-system/scripts/mcp/hook.mjs +119 -0
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +5 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +9 -3
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## Your AI can write the UI. This makes sure it writes *your* UI.
|
|
10
10
|
|
|
11
|
-
A free CLI tool, and a Claude Code plugin with two skills
|
|
11
|
+
A free CLI tool, and a Claude Code plugin with two skills, a bundled local MCP server and an edit hook, that roasts your repo's design system with real data, reviews what you change against it, and generates the rules that keep your AI agent on-system.
|
|
12
12
|
|
|
13
13
|
> **New in 8.0: support for the four big component kits.**
|
|
14
14
|
>
|
|
@@ -97,6 +97,7 @@ These need an agent.
|
|
|
97
97
|
|---|---|
|
|
98
98
|
| <code>... --notes <file.md></code> | The agent's read of this scan, embedded in the report as **"What the numbers mean"**: which findings matter, which good numbers are accidents, what to fix first. Labelled as written by AI and kept apart from the measured numbers. The Claude Code skill writes and passes it automatically; the flag is here so any agent can |
|
|
99
99
|
| <code>... --section "Title" <file.md></code> | A further agent-written chapter after the notes, same styling, same label, sub-headings allowed. Repeatable, so analysis that outgrows the notes still lives inside the report instead of a hand-built page |
|
|
100
|
+
| `... --hook` | The check the Claude Code plugin runs after every edit, for hand-installed setups: reads the hook event on stdin, checks the one file that changed, prints only the findings the edit added as JSON. Always exits 0 |
|
|
100
101
|
| `... --mcp` | The scan as a local MCP server: 5 tools your agent calls while writing UI, from "is there a Button already?" to "review my changes", plus the `roast-fix` prompt that serves the top fix from a fresh scan. See [Live answers over MCP](#live-answers-over-mcp) |
|
|
101
102
|
| <code>/roast-my-design-system</code> (in Claude Code) | The full experience: the roast in chat *and* embedded in the report as "What the numbers mean", the rules offer, and the fix loop with Claude on your own numbers |
|
|
102
103
|
|
|
@@ -179,6 +180,8 @@ The report and the rules file describe the repo as it was at scan time. `--mcp`
|
|
|
179
180
|
|
|
180
181
|
The loop: context before building, find while building, validate before saving, review before finishing.
|
|
181
182
|
|
|
183
|
+
Charts get their own rule, because a chart needs several colours that differ from each other and most design systems never name them. In a repo that keeps a chart palette (its own `--chart-*` or `--series-*` tokens, a `chartColors` entry in the theme, or shadcn's `--chart-1` to `--chart-5` when a chart actually reads them), a colour written by hand in a chart file is a finding that names the palette. In a repo that has charts but no palette, a new chart that paints by hand gets one warning that names the existing chart doing the same and asks for the palette once. The first chart in a repo gets one warning asking for a name. The report never counted chart colours; the live checks now agree with it.
|
|
184
|
+
|
|
182
185
|
The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps (`12px` is `p: 3` on a 4px MUI theme), and `roast_validate` and `roast_review` flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` or `ring-green-500` where the theme names a colour of that kind.
|
|
183
186
|
|
|
184
187
|
### What a session looks like
|
|
@@ -221,7 +224,7 @@ It writes a first draft anyway and runs `roast_validate` before saving:
|
|
|
221
224
|
Second draft: `color: 'text.secondary'`, `p: 1.5`, the radius read from the theme. `roast_validate` again:
|
|
222
225
|
|
|
223
226
|
```
|
|
224
|
-
No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, new colour tokens that twin an existing token, imports of a duplicate the canonical copy replaces, colours and pixel sizes written onto kit components where the theme has a value.
|
|
227
|
+
No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, new colour tokens that twin an existing token, imports of a duplicate the canonical copy replaces, chart colours against the chart palette, colours and pixel sizes written onto kit components where the theme has a value.
|
|
225
228
|
```
|
|
226
229
|
|
|
227
230
|
Four calls, under 800 tokens in total, and the new component reads the theme instead of adding colour number 44. `roast_review` then checks the whole diff the same way before the agent says it is done.
|
|
@@ -285,7 +288,7 @@ Run it inside any repo. Same scanner, same report, straight from npm. The Claude
|
|
|
285
288
|
/plugin install roast-my-design-system@roast-my-design-system
|
|
286
289
|
```
|
|
287
290
|
|
|
288
|
-
What it installs: two skills
|
|
291
|
+
What it installs: two skills, one local MCP server and one edit hook, nothing else. **roast** (`/roast-my-design-system`, or "roast my design system") scans the whole repo and writes the report with Claude's read of the numbers inside it, then walks the fixes with you. **review** (`/roast-my-design-system:review`, or "review my UI changes") checks only what changed, in about a second. The bundled server gives your agent five read-only tools while it writes UI. The hook runs after every file the agent edits and hands back the findings that edit added, so the check happens whether or not the agent thought to ask; a file with nothing new gets no message. Local, no network, no telemetry.
|
|
289
292
|
|
|
290
293
|
If those commands error, your Claude Code is likely older than the plugin marketplace feature. Update Claude Code and retry, or use the manual route below: it works everywhere and installs the same skill.
|
|
291
294
|
|
package/cli/roast.mjs
CHANGED
|
@@ -90,6 +90,8 @@ Run it yourself
|
|
|
90
90
|
number of files it removed, so a scoped scan says so
|
|
91
91
|
--json print the scan summary as JSON on stdout (implies --no-open)
|
|
92
92
|
--check check the working tree's changed files (git diff + untracked)
|
|
93
|
+
--hook Claude Code edit hook: reads the PostToolUse event on stdin,
|
|
94
|
+
checks the one file that changed, answers with findings
|
|
93
95
|
against the design system and print the findings; exits 1
|
|
94
96
|
when something is over the line, so it composes with scripts
|
|
95
97
|
|
|
@@ -123,6 +125,20 @@ if (argv.includes('--mcp')) {
|
|
|
123
125
|
// the server owns the process now; readline keeps it alive until the client
|
|
124
126
|
// closes stdin, and nothing below (the report pipeline) may run
|
|
125
127
|
await new Promise(() => {});
|
|
128
|
+
} else if (argv.includes('--hook')) {
|
|
129
|
+
// Claude Code's PostToolUse hook: the event arrives as JSON on stdin, the
|
|
130
|
+
// findings leave as JSON on stdout. Exit 0 whatever happens — a hook that
|
|
131
|
+
// fails must never stop the agent editing; it only loses one check.
|
|
132
|
+
let raw = '';
|
|
133
|
+
try { raw = readFileSync(0, 'utf8'); } catch { /* no stdin */ }
|
|
134
|
+
try {
|
|
135
|
+
const { hookResult } = await import(pathToFileURL(join(SCRIPTS, 'mcp/hook.mjs')).href);
|
|
136
|
+
const r = hookResult(raw.trim() ? JSON.parse(raw) : {});
|
|
137
|
+
if (r) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.text } }));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`roast-my-design-system --hook: ${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
process.exit(0);
|
|
126
142
|
} else if (argv.includes('--check')) {
|
|
127
143
|
argv.splice(argv.indexOf('--check'), 1);
|
|
128
144
|
const root = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.8.0",
|
|
4
4
|
"mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
|
|
5
5
|
"description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
|
|
6
6
|
"keywords": [
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart colours. A chart needs several colours that differ from each other,
|
|
3
|
+
* and most design systems never name them. Probe over the 126 benchmark
|
|
4
|
+
* repos (2026-09-25): charts in three quarters of them, a named chart palette
|
|
5
|
+
* in a quarter, and the biggest group (43) is charts painting their series
|
|
6
|
+
* by hand with no palette to point at. The report leaves chart colours out
|
|
7
|
+
* (a picture, not paint); the live checks counted every one, so the same
|
|
8
|
+
* file got two verdicts depending on the door. One rule now, three tiers:
|
|
9
|
+
*
|
|
10
|
+
* 1. The repo keeps a chart palette: its own names, or shadcn's default
|
|
11
|
+
* --chart-1..5 when a chart actually reads them. A colour written by
|
|
12
|
+
* hand in a chart is drift, and the finding names the palette.
|
|
13
|
+
* 2. No palette, but a chart already paints by hand. The new chart follows
|
|
14
|
+
* a precedent rather than breaking a rule: one warning per file that
|
|
15
|
+
* names the precedent and asks for the palette, once.
|
|
16
|
+
* 3. No palette, no chart: the first chart. One warning, so the colours
|
|
17
|
+
* get a name before the second chart copies them.
|
|
18
|
+
*
|
|
19
|
+
* shadcn's --chart-1..5 arrive with the install whether or not any chart
|
|
20
|
+
* uses them (6 of the 126 repos), so they count as a palette only when a
|
|
21
|
+
* chart reads them; otherwise the repo is tier 2 with the fix ready-made.
|
|
22
|
+
*/
|
|
23
|
+
import { basename } from 'node:path';
|
|
24
|
+
import { parseColor } from './color.mjs';
|
|
25
|
+
|
|
26
|
+
// chart libraries only: a map or an editor is a renderer too, but its
|
|
27
|
+
// colours are never a series (lib/kitpaint.mjs keeps the wider list)
|
|
28
|
+
export const CHART_IMPORT_RE = /from\s+['"](?:recharts|chart\.js|react-chartjs-2|echarts|echarts-for-react|d3|d3-[\w-]+|@nivo\/[\w-]+|victory|apexcharts|react-apexcharts|highcharts|highcharts-react-official|plotly\.js[\w-]*|react-plotly\.js|@visx\/[\w-]+|@antv\/[\w-]+|@mui\/x-charts|@mantine\/charts|@tremor\/react|lightweight-charts|uplot|uplot-react|frappe-charts|@ant-design\/(?:plots|charts)|chartist|billboard\.js|vega|vega-lite|@observablehq\/plot|@carbon\/charts[\w-]*|react-vis)['"\/]/;
|
|
29
|
+
// a file named for a chart; "graphql" is not a graph, "flowchart" is a diagram
|
|
30
|
+
const CHART_NAME_RE = /(^|[^a-z])(chart|graph(?!ql)|sparkline|donut|histogram|heatmap)/i;
|
|
31
|
+
// an icon, an illustration, a story or a build is not a chart, whatever it
|
|
32
|
+
// imports (likec4's icon set, twenty's storybook, a committed Next chunk)
|
|
33
|
+
const NOT_CHART_RE = /(^|\/)(icons?|illustrations?|__stories__|stories|storybook|\.storybook|static\/chunks|dist|build|out)\/|\.stories\.[jt]sx?$/i;
|
|
34
|
+
const CHART_TOKEN_RE = /(--(?:chart|charts|series|graph|graphs|viz|dataviz|data-viz|visuali[sz]ation)[\w-]*)\s*:\s*([^;{}]+)[;}]/gi;
|
|
35
|
+
// a key in a theme, tokens or palette file: chartColors, charts: {…}, series: […]
|
|
36
|
+
const THEME_FILE_RE = /(^|\/)[\w.-]*(theme|palette|colou?rs?|tokens?|foundations?)[\w.-]*(\/|\.[jt]sx?$)/i;
|
|
37
|
+
const CHART_KEY_RE = /\b((?:chart|charts|series|graph|dataviz|dataViz|visuali[sz]ation)(?:Colou?rs|Palette|colou?rs|palette)?)\s*:\s*[{[]/g;
|
|
38
|
+
const COLOUR_LITERAL_RE = /(['"`])(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?|oklch|oklab)\([^)'"`]*\))\1/g;
|
|
39
|
+
const SHADCN_DEFAULT_RE = /^--chart-[1-5]$/;
|
|
40
|
+
|
|
41
|
+
// a token value that is a colour: hex, rgb(), hsl(), oklch(), or shadcn's
|
|
42
|
+
// bare "220 70% 50%" triplet. "--chart-help: 'Pick a range'" is not one.
|
|
43
|
+
const isColourValue = (v) => {
|
|
44
|
+
const s = v.trim();
|
|
45
|
+
if (parseColor(s)) return true;
|
|
46
|
+
return /^(oklch|oklab|color|lab|lch)\(/.test(s) || /^\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test(s);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A chart file: imports a chart library, or is named for one. */
|
|
50
|
+
export function isChartFile(file, text) {
|
|
51
|
+
if (file && NOT_CHART_RE.test(file)) return false;
|
|
52
|
+
if (text && CHART_IMPORT_RE.test(text)) return true;
|
|
53
|
+
return !!file && CHART_NAME_RE.test(basename(file));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
// how a chart would read a palette entry: var(--chart-1) or the class chart-1
|
|
58
|
+
// for a CSS name, the key itself for a theme key
|
|
59
|
+
const refRe = (name) => name.startsWith('--')
|
|
60
|
+
? new RegExp(`(?:var\\(\\s*${escapeRe(name)}\\b|(?:^|[^\\w-])${escapeRe(name.slice(2))}(?![\\w-]))`)
|
|
61
|
+
: new RegExp(`\\b${escapeRe(name)}\\b`);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the repo already knows about charts, from the same file set the rest
|
|
65
|
+
* of the knowledge reads.
|
|
66
|
+
* @returns { palette: { names, file, shadcnDefault, referenced } | null,
|
|
67
|
+
* precedents: [{ file, count, sample }], chartFiles }
|
|
68
|
+
*/
|
|
69
|
+
export function chartSystemOf(files, read) {
|
|
70
|
+
const names = [], where = new Map();
|
|
71
|
+
const noteToken = (name, file) => { if (!where.has(name)) { names.push(name); where.set(name, file); } };
|
|
72
|
+
for (const f of files.styles ?? []) {
|
|
73
|
+
const text = read(f); if (text === null) continue;
|
|
74
|
+
for (const m of text.matchAll(CHART_TOKEN_RE)) if (isColourValue(m[2])) noteToken(m[1], f);
|
|
75
|
+
}
|
|
76
|
+
const chartTexts = [];
|
|
77
|
+
for (const f of files.code ?? []) {
|
|
78
|
+
const text = read(f); if (text === null) continue;
|
|
79
|
+
// a CSS-in-JS theme or a Tailwind @theme inside a .ts file defines
|
|
80
|
+
// custom properties too
|
|
81
|
+
for (const m of text.matchAll(CHART_TOKEN_RE)) if (isColourValue(m[2])) noteToken(m[1], f);
|
|
82
|
+
if (THEME_FILE_RE.test(f)) {
|
|
83
|
+
for (const m of text.matchAll(CHART_KEY_RE)) {
|
|
84
|
+
if (COLOUR_LITERAL_RE.test(text.slice(m.index, m.index + 600))) noteToken(m[1], f);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (isChartFile(f, text)) chartTexts.push({ f, text });
|
|
88
|
+
}
|
|
89
|
+
const palette = names.length ? { names, file: where.get(names[0]), shadcnDefault: names.every((n) => SHADCN_DEFAULT_RE.test(n)), referenced: false } : null;
|
|
90
|
+
const precedents = [];
|
|
91
|
+
for (const { f, text } of chartTexts) {
|
|
92
|
+
if (palette && !palette.referenced && palette.names.some((n) => refRe(n).test(text))) palette.referenced = true;
|
|
93
|
+
const literals = [...new Set([...text.matchAll(COLOUR_LITERAL_RE)].map((m) => m[2].toLowerCase()))];
|
|
94
|
+
if (literals.length >= 2) precedents.push({ file: f, count: literals.length, sample: literals.slice(0, 3) });
|
|
95
|
+
}
|
|
96
|
+
precedents.sort((a, b) => b.count - a.count);
|
|
97
|
+
return { palette, precedents, chartFiles: chartTexts.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 1: a palette to use; 2: a precedent and a gap; 3: the first chart. */
|
|
101
|
+
export function chartTier(charts) {
|
|
102
|
+
if (!charts) return 3;
|
|
103
|
+
if (charts.palette && (!charts.palette.shadcnDefault || charts.palette.referenced)) return 1;
|
|
104
|
+
if (charts.precedents.length || charts.palette) return 2;
|
|
105
|
+
return 3;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const list = (values, max = 6) => `${values.slice(0, max).join(', ')}${values.length > max ? ` and ${values.length - max} more` : ''}`;
|
|
109
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The findings for the colours a chart file writes by hand.
|
|
113
|
+
* @param colours [{ value, index }] the non-token colour literals in the file
|
|
114
|
+
*/
|
|
115
|
+
export function chartFindings({ file, colours, charts, tokenFile = null }) {
|
|
116
|
+
if (!colours?.length) return [];
|
|
117
|
+
const tier = chartTier(charts);
|
|
118
|
+
const distinct = [...new Set(colours.map((c) => c.value))];
|
|
119
|
+
const first = colours[0].index;
|
|
120
|
+
if (tier === 1) {
|
|
121
|
+
const { names, file: pf } = charts.palette;
|
|
122
|
+
const example = names[0].startsWith('--') ? `var(${names[0]})` : `${names[0]}[0]`;
|
|
123
|
+
return colours.map((c) => ({
|
|
124
|
+
rule: 'chart-colour', severity: 'violation', index: c.index,
|
|
125
|
+
message: `Chart colour ${c.value} written by hand; this repo keeps a chart palette (${list(names, 5)} in ${pf}).`,
|
|
126
|
+
fix: `Read the series colour from the palette (${example}). A series that needs a colour the palette lacks gets a new entry there, once.`,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
const where = tokenFile ?? 'the theme';
|
|
130
|
+
if (tier === 2) {
|
|
131
|
+
const prec = charts.precedents.find((p) => p.file !== file) ?? null;
|
|
132
|
+
const shadcn = charts.palette?.shadcnDefault ? charts.palette : null;
|
|
133
|
+
return [{
|
|
134
|
+
rule: 'chart-palette', severity: 'warning', index: first,
|
|
135
|
+
message: `This chart paints its ${plural(distinct.length, 'series colour')} by hand (${list(distinct)}); the repo has no chart palette${prec ? `, and ${prec.file} already does the same with ${prec.count}` : ''}.`,
|
|
136
|
+
fix: shadcn
|
|
137
|
+
? `The theme already defines --chart-1 to --chart-5 in ${shadcn.file} and no chart reads them. Read them here (var(--chart-1))${prec ? ` and point ${prec.file} at them too` : ''}.`
|
|
138
|
+
: `Name these once as chart tokens (--chart-1, --chart-2 … in ${where}) and read them here${prec ? ` and in ${prec.file}` : ''}, so every chart draws from one set.`,
|
|
139
|
+
}];
|
|
140
|
+
}
|
|
141
|
+
return [{
|
|
142
|
+
rule: 'chart-palette', severity: 'warning', index: first,
|
|
143
|
+
message: `First chart in this repo: ${plural(distinct.length, 'series colour')} written by hand (${list(distinct)}) and no chart palette exists yet.`,
|
|
144
|
+
fix: `Name them once as chart tokens (--chart-1, --chart-2 … in ${where}) so the next chart reads the same set instead of copying these.`,
|
|
145
|
+
}];
|
|
146
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { extname, join } from 'node:path';
|
|
13
13
|
import { walkRepo, readSource, profileRepo } from '../harvest/walk.mjs';
|
|
14
|
+
import { chartSystemOf } from './charts.mjs';
|
|
14
15
|
import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
|
|
15
16
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
16
17
|
import { kitPaintFindings } from './kitpaint.mjs';
|
|
@@ -59,6 +60,7 @@ export { kitPaintFindings };
|
|
|
59
60
|
// Same words as roast_validate, roast_review and --check.
|
|
60
61
|
export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
|
|
61
62
|
export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
|
|
63
|
+
export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
|
|
62
64
|
|
|
63
65
|
// Radius, font size, shadow and typeface: the patterns, so both checkers agree
|
|
64
66
|
// on what a declaration is and what counts as a disciplined value.
|
|
@@ -90,6 +92,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
|
|
|
90
92
|
* tailwind, // { colors, spacing, radii, textSizes, arbitrary }
|
|
91
93
|
* components, // [{ name, file, usageCount, isPage }] the ledger
|
|
92
94
|
* tokenDefs, // [{ name, file, value, canon, darkValue?, darkCanon? }] colour tokens
|
|
95
|
+
* charts, // { palette, precedents, chartFiles } the chart palette or its absence
|
|
93
96
|
* duplicates, // Map<name, { copies: [{ file, usageCount }], strays }>
|
|
94
97
|
* files, // { styles: n, code: n } — how much was read
|
|
95
98
|
* }
|
|
@@ -152,6 +155,8 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
152
155
|
// every colour token each stylesheet defines, light and dark: the
|
|
153
156
|
// `others` tokenTwinFindings compares a new token with (8.6.1)
|
|
154
157
|
tokenDefs: repoTokenDefs(files.styles, read),
|
|
158
|
+
// the chart palette, or the charts painting by hand without one (lib/charts.mjs)
|
|
159
|
+
charts: chartSystemOf(files, read),
|
|
155
160
|
// each duplicated name's copies, their usage and the colours each
|
|
156
161
|
// non-canonical copy hard-codes: the `dupes` avoidedImportFindings reads
|
|
157
162
|
duplicates: dupeCopiesOf(hardDupes, ledger, read),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single version constant for the engine — imported by diagnose (report
|
|
2
2
|
// footer) and rules (generated-by line). This is the bump spot that used to
|
|
3
3
|
// live as a const inside diagnose/index.mjs.
|
|
4
|
-
export const VERSION = '8.
|
|
4
|
+
export const VERSION = '8.8.0';
|
|
5
5
|
// The shape of harvest.json and summary.json. Bumped only when a field is
|
|
6
6
|
// renamed, removed or changes meaning; a new field is not a new schema. A
|
|
7
7
|
// tool comparing two scans compares like with like by this number, not by VERSION.
|
|
@@ -22,6 +22,7 @@ import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
|
|
|
22
22
|
import { oklab } from '../lib/color.mjs';
|
|
23
23
|
import { tokenTwinFindings } from '../lib/tokentwins.mjs';
|
|
24
24
|
import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
|
|
25
|
+
import { isChartFile, chartFindings } from '../lib/charts.mjs';
|
|
25
26
|
|
|
26
27
|
// What this engine measures — shipped with every result, clean or not.
|
|
27
28
|
export const CHECKS = [
|
|
@@ -36,6 +37,7 @@ export const CHECKS = [
|
|
|
36
37
|
'duplicate component definitions',
|
|
37
38
|
'new colour tokens that twin an existing token',
|
|
38
39
|
'imports of a duplicate the canonical copy replaces',
|
|
40
|
+
'chart colours against the chart palette',
|
|
39
41
|
];
|
|
40
42
|
// What a kit or a Tailwind theme adds to the list, so a clean result on an
|
|
41
43
|
// MUI repo says the kit check ran.
|
|
@@ -121,7 +123,18 @@ export function validateContent(content, k) {
|
|
|
121
123
|
// knows which are not paint (a comment, a fallback after a theme read, a
|
|
122
124
|
// compare, SVG artwork), so the generic check stays out of its way.
|
|
123
125
|
let kitOwnsColours = false;
|
|
124
|
-
|
|
126
|
+
// ---------- chart colours ----------
|
|
127
|
+
// A chart's series colours are judged against the chart palette, or its
|
|
128
|
+
// absence, by lib/charts.mjs: the report never counted them, the live
|
|
129
|
+
// checks counted every one, and the same file got two verdicts. On a
|
|
130
|
+
// chart file the chart rule owns colours; the kit judge and the generic
|
|
131
|
+
// colour check stay out of its way.
|
|
132
|
+
const chartFile = !css && !!k.charts && isChartFile(file, text);
|
|
133
|
+
if (chartFile) {
|
|
134
|
+
const paint = got.colors.filter((c) => !k.colorInfo.get(c.value)?.isToken);
|
|
135
|
+
for (const f of chartFindings({ file, colours: paint, charts: k.charts, tokenFile: k.tokens.tokenFile })) add(f.rule, f.severity, f.index, f.message, f.fix);
|
|
136
|
+
}
|
|
137
|
+
if (k.kit?.def && !css && !chartFile) {
|
|
125
138
|
const judged = kitPaintFindings(text, k.kit, { file });
|
|
126
139
|
if (judged && !judged.exempt) {
|
|
127
140
|
kitOwnsColours = true;
|
|
@@ -187,7 +200,7 @@ export function validateContent(content, k) {
|
|
|
187
200
|
for (const c of got.colors) {
|
|
188
201
|
if (seenHere.has(`${c.value}:${c.index}`)) continue;
|
|
189
202
|
seenHere.add(`${c.value}:${c.index}`);
|
|
190
|
-
if (kitOwnsColours) continue; // the kit judge said it, or judged it not paint
|
|
203
|
+
if (kitOwnsColours || chartFile) continue; // the kit judge or the chart rule said it, or judged it not paint
|
|
191
204
|
const info = k.colorInfo.get(c.value);
|
|
192
205
|
if (info?.isToken) continue; // it IS the token's value — the system can see it
|
|
193
206
|
const near = nearestToken(c.value, k);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edit hook — roast_validate without the agent having to ask.
|
|
3
|
+
*
|
|
4
|
+
* The MCP tools only help when the agent decides to call them. In the
|
|
5
|
+
* September 2026 runs Sonnet asked in about a third of sessions and Haiku
|
|
6
|
+
* never did, so the drift it would have caught went straight into the
|
|
7
|
+
* diff. A Claude Code plugin can also register a hook that runs after every
|
|
8
|
+
* Edit or Write; this is that hook. It reads the event, judges the one file
|
|
9
|
+
* that changed with the same engine and the same words as roast_validate,
|
|
10
|
+
* and hands the findings back as context. Silent when there is nothing to
|
|
11
|
+
* say: a hook that talks on every edit is a hook people switch off.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { validateContent } from './engine.mjs';
|
|
18
|
+
import { loadKnowledge } from './knowledge.mjs';
|
|
19
|
+
import { RELEVANT, beforeOf, findingLines } from './tools.mjs';
|
|
20
|
+
|
|
21
|
+
const gitTop = (dir) => {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The files a shell command may have written: everything changed or untracked
|
|
28
|
+
// in the repo that the review would judge. Cheap (two git calls), and the
|
|
29
|
+
// mtime ledger below keeps it from re-judging what the hook already saw.
|
|
30
|
+
function changedFiles(root) {
|
|
31
|
+
try {
|
|
32
|
+
const git = (...a) => execFileSync('git', a, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
33
|
+
const top = realpathSync.native(git('rev-parse', '--show-toplevel').trim());
|
|
34
|
+
const names = [...git('diff', 'HEAD', '--name-only').split('\n'), ...git('ls-files', '--others', '--exclude-standard', '--full-name').split('\n')];
|
|
35
|
+
return [...new Set(names.map((s) => s.trim()).filter((f) => f && RELEVANT.test(f)))].map((f) => join(top, f));
|
|
36
|
+
} catch { return []; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What the hook has judged this session, file → mtime, so a file is judged
|
|
40
|
+
// once per change however it was written, and never twice for one save.
|
|
41
|
+
const ledgerPath = (session) => join(tmpdir(), `roast-hook-${String(session).replace(/[^A-Za-z0-9_-]/g, '')}.json`);
|
|
42
|
+
function readLedger(session) {
|
|
43
|
+
if (!session) return {};
|
|
44
|
+
try { return JSON.parse(readFileSync(ledgerPath(session), 'utf8')); } catch { return {}; }
|
|
45
|
+
}
|
|
46
|
+
function writeLedger(session, ledger) {
|
|
47
|
+
if (!session) return;
|
|
48
|
+
try { writeFileSync(ledgerPath(session), JSON.stringify(ledger)); } catch { /* a lost ledger costs one repeat, nothing more */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const keyOf = (f) => `${f.rule}|${f.message}`;
|
|
52
|
+
/** The findings in `now` that `prior` does not account for, kind by kind. */
|
|
53
|
+
export function newSince(now, prior) {
|
|
54
|
+
const budget = new Map();
|
|
55
|
+
for (const f of prior) budget.set(keyOf(f), (budget.get(keyOf(f)) ?? 0) + 1);
|
|
56
|
+
return now.filter((f) => {
|
|
57
|
+
const n = budget.get(keyOf(f)) ?? 0;
|
|
58
|
+
if (!n) return true;
|
|
59
|
+
budget.set(keyOf(f), n - 1);
|
|
60
|
+
return false;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param payload the PostToolUse event Claude Code writes to stdin
|
|
66
|
+
* @returns { text, total } when a changed file has new findings, else null
|
|
67
|
+
*/
|
|
68
|
+
export function hookResult(payload) {
|
|
69
|
+
const cwd = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
70
|
+
const named = payload?.tool_input?.file_path;
|
|
71
|
+
const fromEditor = typeof named === 'string' && RELEVANT.test(named);
|
|
72
|
+
// An Edit or Write names its file. A shell command (cat > file, sed -i, a
|
|
73
|
+
// generator) names nothing, so every changed UI file is a candidate and
|
|
74
|
+
// the ledger decides which ones are new since the hook last looked.
|
|
75
|
+
const candidates = fromEditor
|
|
76
|
+
? [isAbsolute(named) ? named : resolve(cwd, named)]
|
|
77
|
+
: payload?.tool_name === 'Bash' ? changedFiles(cwd) : [];
|
|
78
|
+
const session = payload?.session_id;
|
|
79
|
+
const ledger = readLedger(session);
|
|
80
|
+
const blocks = [];
|
|
81
|
+
let total = 0, knowledge = null;
|
|
82
|
+
for (const abs of candidates) {
|
|
83
|
+
if (!existsSync(abs)) continue;
|
|
84
|
+
const real = realpathSync.native(abs);
|
|
85
|
+
const mtime = statSync(real).mtimeMs;
|
|
86
|
+
if (!fromEditor && ledger[real] === mtime) continue;
|
|
87
|
+
ledger[real] = mtime;
|
|
88
|
+
// The repo is the session's working directory when the file lives under
|
|
89
|
+
// it (the same root the MCP server would be pointed at); a file edited
|
|
90
|
+
// elsewhere is judged against its own repository.
|
|
91
|
+
const under = (root) => root && real.startsWith(realpathSync.native(root) + '/');
|
|
92
|
+
const root = under(cwd) ? cwd : gitTop(dirname(real));
|
|
93
|
+
if (!root) continue;
|
|
94
|
+
const k = knowledge?.root === root ? knowledge : (knowledge = loadKnowledge(root));
|
|
95
|
+
const r = judge(real, root, k);
|
|
96
|
+
if (r) { blocks.push(r.text); total += r.total; }
|
|
97
|
+
}
|
|
98
|
+
writeLedger(session, ledger);
|
|
99
|
+
if (!blocks.length) return null;
|
|
100
|
+
blocks.push('Fix these now, in the repo\'s own vocabulary as each fix says (a token, a spacing step, a theme path), not by deleting the code. A value that is deliberate stays, with a one-line comment saying why.');
|
|
101
|
+
return { text: blocks.join('\n'), total };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One file against the system: only what the working copy added since HEAD. */
|
|
105
|
+
function judge(real, root, k) {
|
|
106
|
+
const rel = relative(realpathSync.native(root), real).split('\\').join('/');
|
|
107
|
+
const text = readFileSync(real, 'utf8');
|
|
108
|
+
const before = beforeOf(k, rel);
|
|
109
|
+
const { findings: all, exempt } = validateContent({ text, file: rel, before }, k);
|
|
110
|
+
if (exempt || !all.length) return null;
|
|
111
|
+
// Only what this edit added. A legacy file carries its old findings on
|
|
112
|
+
// every touch; repeating them after each edit is noise, and the review
|
|
113
|
+
// (--check, roast_review) already reports them once at the end. Same rule
|
|
114
|
+
// as the September runs measured by: a finding is new when the file has
|
|
115
|
+
// more of it than the committed version did.
|
|
116
|
+
const findings = typeof before === 'string' ? newSince(all, validateContent({ text: before, file: rel }, k).findings) : all;
|
|
117
|
+
if (!findings.length) return null;
|
|
118
|
+
return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k).join('\n')}`, total: findings.length };
|
|
119
|
+
}
|
|
@@ -26,6 +26,7 @@ import { decideProfile, profileOf } from '../profiles/index.mjs';
|
|
|
26
26
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
27
27
|
import { repoTokenDefs } from '../lib/tokentwins.mjs';
|
|
28
28
|
import { dupeCopiesOf } from '../lib/avoidedimports.mjs';
|
|
29
|
+
import { chartSystemOf } from '../lib/charts.mjs';
|
|
29
30
|
|
|
30
31
|
const MAX_DEPTH = 14; // same ruler as the harvest CLI
|
|
31
32
|
|
|
@@ -128,6 +129,9 @@ export function loadKnowledge(root) {
|
|
|
128
129
|
// Every colour token each stylesheet defines, light and dark, so a new one
|
|
129
130
|
// can be compared with the ones already there (lib/tokentwins).
|
|
130
131
|
const tokenDefs = repoTokenDefs(files.styles, read);
|
|
132
|
+
// the chart palette the repo keeps, or the charts that paint by hand
|
|
133
|
+
// without one (lib/charts.mjs)
|
|
134
|
+
const charts = chartSystemOf(files, read);
|
|
131
135
|
|
|
132
136
|
return {
|
|
133
137
|
root,
|
|
@@ -156,6 +160,7 @@ export function loadKnowledge(root) {
|
|
|
156
160
|
dupeByName,
|
|
157
161
|
dupeCopies,
|
|
158
162
|
tokenDefs,
|
|
163
|
+
charts,
|
|
159
164
|
neverImported,
|
|
160
165
|
// stock, not debt: see profiles/index.mjs
|
|
161
166
|
vendoredUi: P.vendoredUi,
|
|
@@ -15,6 +15,8 @@ import { hexRgb } from '../lib/nearpairs.mjs';
|
|
|
15
15
|
import { distinctTypefaces } from '../lib/typefaces.mjs';
|
|
16
16
|
|
|
17
17
|
const CONTEXT_BUDGET = 1600; // chars ≈ 400 tokens
|
|
18
|
+
// the files a review or an edit hook judges; everything else is not UI
|
|
19
|
+
export const RELEVANT = /\.(tsx|jsx|ts|js|vue|svelte|css|scss|sass|less)$/i;
|
|
18
20
|
const MAX_VALIDATE_CHARS = 200_000; // a whole file, not a whole bundle
|
|
19
21
|
const approxTokens = (s) => Math.ceil(s.length / 4);
|
|
20
22
|
|
|
@@ -288,7 +290,7 @@ function headVersion(gitRoot, gitPath) {
|
|
|
288
290
|
} catch { return undefined; }
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
|
-
function beforeOf(k, file) {
|
|
293
|
+
export function beforeOf(k, file) {
|
|
292
294
|
if (!file) return undefined;
|
|
293
295
|
try {
|
|
294
296
|
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: k.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
@@ -304,13 +306,18 @@ export function validate(k, { code, file = null } = {}) {
|
|
|
304
306
|
const { findings, exempt } = validateContent({ text: code, file, before: beforeOf(k, file) }, k);
|
|
305
307
|
if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
|
|
306
308
|
if (!findings.length) return cleanResultText(k);
|
|
309
|
+
return findingLines(findings, k).join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The finding list as roast_validate prints it; the edit hook prints the same. */
|
|
313
|
+
export function findingLines(findings, k) {
|
|
307
314
|
const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
|
|
308
315
|
for (const f of findings.slice(0, 12)) {
|
|
309
316
|
L.push(`${f.severity === 'violation' ? '✕' : '⚠'} L${f.line} ${f.message}${f.fix ? `\n Fix: ${f.fix}` : ''}`);
|
|
310
317
|
}
|
|
311
318
|
if (findings.length > 12) L.push(`(+${findings.length - 12} more of the same kinds)`);
|
|
312
319
|
L.push(`Checked: ${checksFor(k).join(', ')}.`);
|
|
313
|
-
return L
|
|
320
|
+
return L;
|
|
314
321
|
}
|
|
315
322
|
|
|
316
323
|
// ---------- roast_review ----------
|
|
@@ -336,7 +343,6 @@ export function reviewData(k) {
|
|
|
336
343
|
} catch {
|
|
337
344
|
return { text: 'Not a git repository (or git is unavailable), so there is no diff to review. Use roast_validate with the code instead.', total: 0 };
|
|
338
345
|
}
|
|
339
|
-
const RELEVANT = /\.(tsx|jsx|ts|js|vue|svelte|css|scss|sass|less)$/i;
|
|
340
346
|
const changed = names.filter((f) => RELEVANT.test(f));
|
|
341
347
|
if (!changed.length) return { text: 'No changed UI or style files in the working tree. Nothing to review.', total: 0 };
|
|
342
348
|
|