roast-my-design-system 5.10.2 → 5.11.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 +2 -0
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +23 -13
- package/skills/roast-my-design-system/scripts/lib/declarations.mjs +53 -0
- package/skills/roast-my-design-system/scripts/lib/exempt.mjs +50 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +21 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +80 -1
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +17 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +11 -5
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
|
|
10
10
|
|
|
11
|
+
> **New in 5.11: the checker and the CI guard stop contradicting each other.** `roast --check` and [guard-my-design-system](https://github.com/gregkozakiewicz/guard-my-design-system) both answer "does this change fit the system", and they disagreed in seven places. A second hand-rolled `<Button>` passed the guard. A new radius, font size, shadow or typeface passed `--check` and was then stopped in CI. Both now measure the same things. And both leave alone the files that cannot be on-system: email, print, OG cards, PDF invoices, pixel renderers and SVG artwork. A skipped file is never called clean; the result says what was left unjudged and why.
|
|
12
|
+
|
|
11
13
|
> **New in 5.10: it reads Tailwind v3 shadcn, and every colour space.** shadcn on Tailwind v3 stores colour tokens as bare HSL channels, for the opacity trick. So a textbook shadcn repo used to scan as "2 colours, none defined as CSS variables". It now reads those tokens. It finds the greys (an oklch palette used to report 0). And it spots twins across notations: a token written as hsl and a hardcoded copy written as rgb are the same colour, and now count as a near-identical pair. Checked before and after on 6 real shadcn repos.
|
|
12
14
|
|
|
13
15
|
> **New in 5.9: the agent card knows which doors your rules actually reach.** The report now states, as plain fact, which tools can read the rules files you have. Claude Code reads CLAUDE.md. Codex reads AGENTS.md. Cursor reads AGENTS.md and `.cursor/rules`. Where a door is missing, the card shows the one-line fix: Claude Code skips AGENTS.md, and a CLAUDE.md containing the single line `@AGENTS.md` closes the gap. The scan also finds rules files nested in subfolders, which is how monorepos really do it (twenty carries 35 AGENTS.md files; a root-only look reported 1). It recognises GEMINI.md, `.windsurf/rules` and `.github/instructions` too.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.11.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 34 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": [
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* New in 2.0 — 1.0 only ever read a clean globals.css; this reads the mess.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
|
+
import {
|
|
11
|
+
EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, svgHeavy,
|
|
12
|
+
} from '../lib/exempt.mjs';
|
|
10
13
|
import { join } from 'node:path';
|
|
11
14
|
|
|
12
15
|
// ---------- counters ----------
|
|
@@ -48,6 +51,14 @@ export const tripletToHsl = (v) => {
|
|
|
48
51
|
const m = HSL_TRIPLET_RE.exec(v);
|
|
49
52
|
return m ? `hsl(${m[1]} ${m[2]}% ${m[3]}%${m[4] ? ` / ${m[4]}` : ''})` : null;
|
|
50
53
|
};
|
|
54
|
+
// Loose hex in CODE (not in a style block, a class string or a stylesheet) is
|
|
55
|
+
// ambiguous: #RGBA shorthand is legal CSS but vanishingly rare in code, while
|
|
56
|
+
// #1042 as an order id, ticket or issue number is everyday. Read as a colour
|
|
57
|
+
// it expands to #11004422 and the review accuses an order id of being a
|
|
58
|
+
// hardcoded colour (found on the demo repo, 2026-09-07). In a colour context
|
|
59
|
+
// all four lengths still count; only the loose pass skips 4-digit.
|
|
60
|
+
const isIdentifierHex = (raw) => /^#[0-9a-f]{4}$/i.test(raw);
|
|
61
|
+
|
|
51
62
|
// hsl(var(--primary)) is a reference to a colour, never a colour, and
|
|
52
63
|
// oklch(0.35 0.08 ${hue}) is a template with a hole in it: neither may sit in
|
|
53
64
|
// the palette as its own value.
|
|
@@ -122,8 +133,9 @@ function isStaticInline(block) {
|
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
// Render-to-image surfaces (satori OG cards, react-pdf invoices) have inline
|
|
125
|
-
// styles as their ONLY styling mechanism
|
|
126
|
-
|
|
136
|
+
// styles as their ONLY styling mechanism, the same honesty rule as email. The
|
|
137
|
+
// patterns live in lib/exempt.mjs so the report, roast --check and the guard
|
|
138
|
+
// all skip the same files.
|
|
127
139
|
|
|
128
140
|
/** Extract inline style objects: style={{ ... }} */
|
|
129
141
|
function inlineStyleBlocks(src) {
|
|
@@ -199,6 +211,7 @@ export function extractStyling(src, { css = false } = {}) {
|
|
|
199
211
|
// colour consts) — everything not already collected above
|
|
200
212
|
const seen = new Set(out.colors.map((c) => c.index));
|
|
201
213
|
for (const m of src.matchAll(HEX_RE)) {
|
|
214
|
+
if (isIdentifierHex(m[0])) continue;
|
|
202
215
|
if (![...seen].some((i) => Math.abs(i - m.index) < 4)) out.colors.push({ value: normalizeHex(m[0]), index: m.index });
|
|
203
216
|
}
|
|
204
217
|
for (const m of src.matchAll(/!\s*important/gi)) out.important.push({ index: m.index });
|
|
@@ -287,15 +300,9 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
287
300
|
// Email templates (and print styles) MUST inline their styling — that's
|
|
288
301
|
// correct practice, not mess. Counting them would hand a sharp dev an easy
|
|
289
302
|
// reason to discredit the whole report.
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
const ARTWORK_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
|
|
294
|
-
// A component that is mostly SVG markup (theme-preview mocks, decorative
|
|
295
|
-
// scenes) is drawing, not styling — regardless of its filename.
|
|
296
|
-
const svgHeavy = (src) => (src.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
|
|
297
|
-
// Canvas/scene renderers draw pixels; their colour literals are not UI styling.
|
|
298
|
-
const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
|
|
303
|
+
// Email, artwork, mostly-SVG files and pixel renderers: all imported from
|
|
304
|
+
// lib/exempt.mjs, the one list every checker reads.
|
|
305
|
+
const EXEMPT_RE = EMAIL_PRINT_RE, ARTWORK_RE = ARTWORK_NAME_RE;
|
|
299
306
|
// Colour-picker palettes and design-field option lists are user-facing data.
|
|
300
307
|
const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
|
|
301
308
|
|
|
@@ -357,7 +364,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
357
364
|
// code: its colours are the picture's, not the product's palette. Until
|
|
358
365
|
// 5.10.0 they were the only "strays" a clean shadcn repo had, and they
|
|
359
366
|
// triggered the "every single one is hardcoded" banner on it.
|
|
360
|
-
const renderToImage = RENDER_TO_IMAGE_RE.test(src) ||
|
|
367
|
+
const renderToImage = RENDER_TO_IMAGE_RE.test(src) || OG_ROUTE_RE.test(f);
|
|
361
368
|
if (renderToImage) continue;
|
|
362
369
|
|
|
363
370
|
// Tailwind classes
|
|
@@ -400,7 +407,10 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
400
407
|
// — cheap approximation: any hex in a .ts/.js file counts as a colour in code.
|
|
401
408
|
if (/\.(ts|js)$/.test(f)) {
|
|
402
409
|
for (const m of src.matchAll(HEX_RE)) {
|
|
403
|
-
//
|
|
410
|
+
// 4-digit shorthand in code is an identifier far more often than a
|
|
411
|
+
// colour (#1042 = an order id, not #11004422): same rule as the MCP
|
|
412
|
+
// validator, so both measure with one ruler.
|
|
413
|
+
if (isIdentifierHex(m[0])) continue;
|
|
404
414
|
colors.add(normalizeHex(m[0]), f);
|
|
405
415
|
}
|
|
406
416
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The styling declarations beyond colour and spacing that both checkers judge:
|
|
3
|
+
* radius, font size, shadow and typeface.
|
|
4
|
+
*
|
|
5
|
+
* They live here rather than in either checker because keeping two copies is
|
|
6
|
+
* exactly how the engine and guard-my-design-system drifted apart in the first
|
|
7
|
+
* place. One set of patterns, one idea of what counts as a value, two scopes:
|
|
8
|
+
* the engine reads whole files, the guard reads added lines.
|
|
9
|
+
*
|
|
10
|
+
* The whole trimmed value is the unit of comparison, exactly as the harvest
|
|
11
|
+
* counts it, so "already in the system" means the same thing in both places.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** kind: the finding's name · re: how to spot it · learned: the harvest key */
|
|
15
|
+
export const EXTRA_KINDS = [
|
|
16
|
+
{ kind: 'radius', noun: 'border radius', plural: 'radii', re: /border-radius\s*:\s*([^;{}]+)/i, learned: 'radii' },
|
|
17
|
+
{ kind: 'fontsize', noun: 'font size', plural: 'font sizes', re: /(?:^|[^-\w])font-size\s*:\s*([^;{}]+)/i, learned: 'fontSizes' },
|
|
18
|
+
{ kind: 'shadow', noun: 'shadow', plural: 'shadows', re: /box-shadow\s*:\s*([^;{}]+)/i, learned: 'shadows' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const FONT_LINE_RE = /font-family\s*:\s*([^;{}]+)/i;
|
|
22
|
+
|
|
23
|
+
// Disciplined values that are never sins: token use, resets, inheritance.
|
|
24
|
+
export const BENIGN_VALUE_RE = /^(var\(--[\w-]+\)|inherit|initial|unset|none|normal|0)$/i;
|
|
25
|
+
const FONT_BENIGN_RE = /^(var\(--[\w-]+\)|inherit)$/i;
|
|
26
|
+
|
|
27
|
+
/** The first declaration of one kind on one line, or null. The guard's door. */
|
|
28
|
+
export function extraValue(re, text) {
|
|
29
|
+
const m = re.exec(text);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
const v = m[1].trim().replace(/\s+/g, ' ');
|
|
32
|
+
return BENIGN_VALUE_RE.test(v) ? null : v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every such declaration in a whole stylesheet, with positions. The engine's door. */
|
|
36
|
+
export function* extraDeclarations(text) {
|
|
37
|
+
for (const { kind, noun, plural, re, learned } of EXTRA_KINDS) {
|
|
38
|
+
for (const m of text.matchAll(new RegExp(re.source, 'gi'))) {
|
|
39
|
+
const v = m[1].trim().replace(/\s+/g, ' ');
|
|
40
|
+
if (BENIGN_VALUE_RE.test(v)) continue;
|
|
41
|
+
yield { kind, noun, plural, learned, value: v, index: m.index + m[0].indexOf(m[1]) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every font-family declaration worth judging, raw value and position. */
|
|
47
|
+
export function* fontDeclarations(text) {
|
|
48
|
+
for (const m of text.matchAll(new RegExp(FONT_LINE_RE.source, 'gi'))) {
|
|
49
|
+
const raw = m[1].trim();
|
|
50
|
+
if (FONT_BENIGN_RE.test(raw)) continue;
|
|
51
|
+
yield { raw, index: m.index + m[0].indexOf(m[1]) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honesty exemptions — the two places where styling genuinely cannot be
|
|
3
|
+
* on-system, so judging it would be crying wolf.
|
|
4
|
+
*
|
|
5
|
+
* Email and print: mail clients strip stylesheets and print has no cascade to
|
|
6
|
+
* inherit, so the styling has to be inline and hardcoded. That is the medium,
|
|
7
|
+
* not a lapse.
|
|
8
|
+
*
|
|
9
|
+
* Artwork: a file named Icon, Logo, Badge or Illustration carries the colours
|
|
10
|
+
* of the drawing itself, and a drawing is not interface. The name alone is not
|
|
11
|
+
* enough though, or every Badge component would walk free: the contents must
|
|
12
|
+
* actually draw SVG. A Badge that is plain styled UI gets judged like anything
|
|
13
|
+
* else. A file that is mostly SVG earns it on contents alone, whatever it is
|
|
14
|
+
* called.
|
|
15
|
+
*
|
|
16
|
+
* Render-to-image: an OG card or a PDF invoice is a picture drawn with code.
|
|
17
|
+
* satori and react-pdf accept nothing but inline styles, so there is no
|
|
18
|
+
* on-system way to write one.
|
|
19
|
+
*
|
|
20
|
+
* Renderers: a canvas or scene renderer draws pixels. Its colour literals are
|
|
21
|
+
* the picture, not the product's palette.
|
|
22
|
+
*
|
|
23
|
+
* Shared by the engine (whole files) and by guard-my-design-system (added
|
|
24
|
+
* lines), so both give the same answer about the same file.
|
|
25
|
+
*/
|
|
26
|
+
export const EMAIL_PRINT_RE = /email|(^|[/.])print([/.]|$)/i;
|
|
27
|
+
export const ARTWORK_NAME_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
|
|
28
|
+
export const SVG_MARKUP_RE = /<(svg|path|rect|circle|ellipse|polygon|defs|mask)\b/i;
|
|
29
|
+
export const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
|
|
30
|
+
export const OG_ROUTE_RE = /(^|\/)api\/og\//;
|
|
31
|
+
export const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
|
|
32
|
+
|
|
33
|
+
/** Mostly drawing rather than styling, whatever the file is called. */
|
|
34
|
+
export const svgHeavy = (text) =>
|
|
35
|
+
(text.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why this file is exempt from judgement, as a sentence, or null when it is
|
|
39
|
+
* fair game. Needs the text as well as the name: the artwork exemption is
|
|
40
|
+
* earned by drawing, not by being called Icon.
|
|
41
|
+
*/
|
|
42
|
+
export function exemptReason(file, text = '') {
|
|
43
|
+
if (!file) return null;
|
|
44
|
+
if (EMAIL_PRINT_RE.test(file)) return 'email and print styling has to be inline, because there is no cascade to inherit';
|
|
45
|
+
if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
|
|
46
|
+
if (OG_ROUTE_RE.test(file) || RENDER_TO_IMAGE_RE.test(text)) return 'a render-to-image surface accepts nothing but inline styling, so there is no on-system way to write one';
|
|
47
|
+
if (svgHeavy(text)) return 'the file is mostly drawing rather than styling';
|
|
48
|
+
if (ARTWORK_NAME_RE.test(file) && SVG_MARKUP_RE.test(text)) return 'the colours belong to the artwork, not to the interface';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -13,12 +13,27 @@ import { extname, join } from 'node:path';
|
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { walkRepo } from '../harvest/walk.mjs';
|
|
15
15
|
import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
|
|
16
|
+
import { harvestComponents, definedComponents } from '../harvest/components.mjs';
|
|
16
17
|
import { loadExclusions } from './exclusions.mjs';
|
|
17
18
|
import { hexRgb } from './nearpairs.mjs';
|
|
18
19
|
import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
|
|
19
20
|
|
|
20
21
|
export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS };
|
|
21
22
|
|
|
23
|
+
// The components a piece of text defines. With the ledger below, a guard can
|
|
24
|
+
// tell a second <Button> from an edit to the first one.
|
|
25
|
+
export { definedComponents };
|
|
26
|
+
|
|
27
|
+
// The files no checker should judge. Exported so a guard reads the same list
|
|
28
|
+
// as the engine rather than keeping a copy that drifts.
|
|
29
|
+
export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, SVG_MARKUP_RE, exemptReason } from './exempt.mjs';
|
|
30
|
+
|
|
31
|
+
// Radius, font size, shadow and typeface: the patterns, so both checkers agree
|
|
32
|
+
// on what a declaration is and what counts as a disciplined value.
|
|
33
|
+
export {
|
|
34
|
+
EXTRA_KINDS, FONT_LINE_RE, BENIGN_VALUE_RE, extraValue, extraDeclarations, fontDeclarations,
|
|
35
|
+
} from './declarations.mjs';
|
|
36
|
+
|
|
22
37
|
// Same classification walkRepo uses, exposed so a caller looking at one file
|
|
23
38
|
// (a diff hunk) treats it the way the full scan would.
|
|
24
39
|
const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs']);
|
|
@@ -41,6 +56,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
|
|
|
41
56
|
* shadows, // [{ value, count, files }] every box-shadow declared
|
|
42
57
|
* fontFamilies, // [{ value, count, files }] every family declared
|
|
43
58
|
* tailwind, // { colors, spacing, radii, textSizes, arbitrary }
|
|
59
|
+
* components, // [{ name, file, usageCount, isPage }] the ledger
|
|
44
60
|
* files, // { styles: n, code: n } — how much was read
|
|
45
61
|
* }
|
|
46
62
|
*/
|
|
@@ -74,6 +90,11 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
74
90
|
shadows: t.shadows,
|
|
75
91
|
fontFamilies: t.fontFamilies,
|
|
76
92
|
tailwind: t.tailwind,
|
|
93
|
+
// Trimmed to the four fields a guard can act on: what it is called, where
|
|
94
|
+
// it lives, how much the repo leans on it, and whether it is a page (pages
|
|
95
|
+
// are routes, not reusable parts, so two of a name is not a duplicate).
|
|
96
|
+
components: harvestComponents(repoRoot, files.code).components
|
|
97
|
+
.map(({ name, file, usageCount, isPage }) => ({ name, file, usageCount, isPage })),
|
|
77
98
|
files: { styles: files.styles.length, code: files.code.length },
|
|
78
99
|
};
|
|
79
100
|
}
|
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
import { extractStyling } from '../harvest/tokens.mjs';
|
|
14
14
|
import { definedComponents } from '../harvest/components.mjs';
|
|
15
15
|
import { hexRgb } from '../lib/nearpairs.mjs';
|
|
16
|
+
import { exemptReason } from '../lib/exempt.mjs';
|
|
17
|
+
import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
|
|
18
|
+
import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
|
|
16
19
|
|
|
17
20
|
// What this engine measures — shipped with every result, clean or not.
|
|
18
21
|
export const CHECKS = [
|
|
19
22
|
'hardcoded colours vs the token set',
|
|
20
23
|
'near-identical colour twins',
|
|
21
24
|
'off-scale spacing',
|
|
25
|
+
'off-scale radii, font sizes and shadows',
|
|
26
|
+
'typefaces outside the system',
|
|
22
27
|
'arbitrary bracket values',
|
|
23
28
|
'static inline style blocks',
|
|
24
29
|
'!important',
|
|
@@ -60,10 +65,16 @@ function nearestToken(value, k) {
|
|
|
60
65
|
* Validate one piece of content against the repo's knowledge.
|
|
61
66
|
* @param content { text, file? } — file name decides css-vs-code mode and
|
|
62
67
|
* lets the duplicate check excuse a component's own existing file
|
|
63
|
-
* @returns { findings: [{ rule, severity, line, message, fix? }], checked
|
|
68
|
+
* @returns { findings: [{ rule, severity, line, message, fix? }], checked,
|
|
69
|
+
* exempt? } — exempt is a sentence saying why the file was not judged
|
|
64
70
|
*/
|
|
65
71
|
export function validateContent(content, k) {
|
|
66
72
|
const { text, file = null } = content;
|
|
73
|
+
// Some files cannot be on-system by their nature. Judging them is how a
|
|
74
|
+
// checker earns its reputation for crying wolf, and a checker people
|
|
75
|
+
// distrust gets switched off. Say nothing, and say why nothing was said.
|
|
76
|
+
const exempt = exemptReason(file, text);
|
|
77
|
+
if (exempt) return { findings: [], checked: CHECKS, exempt };
|
|
67
78
|
const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
|
|
68
79
|
const got = extractStyling(text, { css });
|
|
69
80
|
const findings = [];
|
|
@@ -135,6 +146,57 @@ export function validateContent(content, k) {
|
|
|
135
146
|
'If the value repeats it is a decision: name it as a token. If it does not, use the nearest scale step.');
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
// ---------- the other declared scales, and the typeface ----------
|
|
150
|
+
// Stylesheets only. In code these live inside class strings, where the scale
|
|
151
|
+
// is Tailwind's and the arbitrary-bracket check already covers straying off
|
|
152
|
+
// it. The rule is the guard's rule: a value the repo already declares is
|
|
153
|
+
// consistency, not a sin, and only a genuinely new one is worth saying.
|
|
154
|
+
if (css) {
|
|
155
|
+
const localExtras = new Map(), localFaces = new Map();
|
|
156
|
+
for (const d of extraDeclarations(text)) {
|
|
157
|
+
const key = `${d.kind}|${d.value}`;
|
|
158
|
+
localExtras.set(key, (localExtras.get(key) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
for (const f of fontDeclarations(text)) {
|
|
161
|
+
const face = typefaceOf(f.raw);
|
|
162
|
+
if (face) localFaces.set(face, (localFaces.get(face) ?? 0) + 1);
|
|
163
|
+
}
|
|
164
|
+
const SEEN = { radii: k.radiiSeen, fontSizes: k.fontSizesSeen, shadows: k.shadowsSeen };
|
|
165
|
+
|
|
166
|
+
for (const d of extraDeclarations(text)) {
|
|
167
|
+
const seen = SEEN[d.learned] ?? new Map();
|
|
168
|
+
if (discount(seen.get(d.value), localExtras, `${d.kind}|${d.value}`) > 0) continue;
|
|
169
|
+
const near = nearestDeclared(d.value, seen);
|
|
170
|
+
const others = seen.size - (seen.has(d.value) ? 1 : 0);
|
|
171
|
+
if (others) {
|
|
172
|
+
add(`off-scale-${d.kind}`, 'violation', d.index,
|
|
173
|
+
`New one-off ${d.noun} ${d.value}. The repo already declares ${others} other ${others === 1 ? d.noun : d.plural}.`,
|
|
174
|
+
near ? `Closest value this repo already uses: ${near.value} (${near.count}x).`
|
|
175
|
+
: `It matches none of them. If it is a real decision it is a token, not a one-off.`);
|
|
176
|
+
} else {
|
|
177
|
+
add(`off-scale-${d.kind}`, 'warning', d.index,
|
|
178
|
+
`${d.value} is the first ${d.noun} this repo declares.`,
|
|
179
|
+
`Nothing to compare it against yet. Put it in the token layer so the next ${d.noun} has a scale to join.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const f of fontDeclarations(text)) {
|
|
184
|
+
const face = typefaceOf(f.raw);
|
|
185
|
+
if (!face || GENERIC_FONTS.has(face.toLowerCase())) continue;
|
|
186
|
+
if (discount(k.faceCounts?.get(face), localFaces, face) > 0) continue;
|
|
187
|
+
const declared = [...(k.faceCounts?.keys() ?? [])].filter((x) => x !== face);
|
|
188
|
+
if (declared.length) {
|
|
189
|
+
add('off-system-typeface', 'violation', f.index,
|
|
190
|
+
`New typeface ${face}. The system already declares ${declared.join(', ')}.`,
|
|
191
|
+
`Use a family the system declares, or add ${face} to the token layer before anything uses it.`);
|
|
192
|
+
} else {
|
|
193
|
+
add('off-system-typeface', 'warning', f.index,
|
|
194
|
+
`${face} is the first typeface this repo declares.`,
|
|
195
|
+
'Declare it once, in the token layer, so everything else can inherit it.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
138
200
|
// ---------- discipline ----------
|
|
139
201
|
for (const b of got.inlineBlocks) {
|
|
140
202
|
add('inline-style', 'violation', b.index,
|
|
@@ -174,6 +236,23 @@ function looksLikeCss(text) {
|
|
|
174
236
|
return !/<[A-Za-z][\w.]*[\s/>]/.test(text) && /[.#:\w[\]-]+\s*\{[^}]*:/.test(text);
|
|
175
237
|
}
|
|
176
238
|
|
|
239
|
+
/** Nearest value the repo already declares, for a scale that has lengths in it. */
|
|
240
|
+
function nearestDeclared(value, seen) {
|
|
241
|
+
const px = toPx(value);
|
|
242
|
+
if (px === null || !seen.size) return null;
|
|
243
|
+
let best = null;
|
|
244
|
+
for (const [v, count] of seen) {
|
|
245
|
+
// the file under review is inside the scan, so the value being judged is
|
|
246
|
+
// in this map too. Pointing at itself is not advice.
|
|
247
|
+
if (v === value) continue;
|
|
248
|
+
const p = toPx(v);
|
|
249
|
+
if (p === null) continue;
|
|
250
|
+
const d = Math.abs(p - px);
|
|
251
|
+
if (!best || d < best.d) best = { value: v, count, d };
|
|
252
|
+
}
|
|
253
|
+
return best;
|
|
254
|
+
}
|
|
255
|
+
|
|
177
256
|
function nearestRepoSpacing(value, k) {
|
|
178
257
|
const px = toPx(value);
|
|
179
258
|
if (px === null || !k.spacingSeen.size) return undefined;
|
|
@@ -20,6 +20,7 @@ import { harvestContext } from '../harvest/context.mjs';
|
|
|
20
20
|
import { loadExclusions } from '../lib/exclusions.mjs';
|
|
21
21
|
import { resolveWorkspaces } from '../lib/workspaces.mjs';
|
|
22
22
|
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
23
|
+
import { typefaceOf } from '../lib/typefaces.mjs';
|
|
23
24
|
import { hexRgb } from '../lib/nearpairs.mjs';
|
|
24
25
|
|
|
25
26
|
const MAX_DEPTH = 14; // same ruler as the harvest CLI
|
|
@@ -57,6 +58,18 @@ export function loadKnowledge(root) {
|
|
|
57
58
|
// spacing the repo already uses: raw CSS values with counts, plus whether
|
|
58
59
|
// the repo styles spacing through Tailwind at all (decides what "on-scale" means)
|
|
59
60
|
const spacingSeen = new Map(tokens.spacing.map((s) => [s.value, s.count]));
|
|
61
|
+
|
|
62
|
+
// the other declared scales, same shape: value → how often the repo says it.
|
|
63
|
+
// Typefaces collapse to the face itself, so "Inter" and "Inter, sans-serif"
|
|
64
|
+
// are one voice rather than two.
|
|
65
|
+
const radiiSeen = new Map((tokens.radii ?? []).map((r) => [r.value, r.count]));
|
|
66
|
+
const fontSizesSeen = new Map((tokens.fontSizes ?? []).map((f) => [f.value, f.count]));
|
|
67
|
+
const shadowsSeen = new Map((tokens.shadows ?? []).map((s) => [s.value, s.count]));
|
|
68
|
+
const faceCounts = new Map();
|
|
69
|
+
for (const f of tokens.fontFamilies ?? []) {
|
|
70
|
+
const face = typefaceOf(f.value);
|
|
71
|
+
if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
|
|
72
|
+
}
|
|
60
73
|
const twSpacingUse = (tokens.tailwind?.spacing ?? []).reduce((sum, s) => sum + s.count, 0);
|
|
61
74
|
const usesTailwind = twSpacingUse >= 20;
|
|
62
75
|
|
|
@@ -98,6 +111,10 @@ export function loadKnowledge(root) {
|
|
|
98
111
|
tokenColors,
|
|
99
112
|
tokenColorRgb: tokenColors.map((v) => ({ value: v, rgb: hexRgb(v) })).filter((t) => t.rgb),
|
|
100
113
|
spacingSeen,
|
|
114
|
+
radiiSeen,
|
|
115
|
+
fontSizesSeen,
|
|
116
|
+
shadowsSeen,
|
|
117
|
+
faceCounts,
|
|
101
118
|
usesTailwind,
|
|
102
119
|
byName,
|
|
103
120
|
canonical,
|
|
@@ -201,7 +201,8 @@ function toPxLocal(len) {
|
|
|
201
201
|
export function validate(k, { code, file = null } = {}) {
|
|
202
202
|
if (typeof code !== 'string' || !code.trim()) return invalidInput('Send the code you are about to save (and ideally its file path).');
|
|
203
203
|
if (file != null && typeof file !== 'string') return invalidInput('file is optional, but when sent it must be a repo-relative path as a string.');
|
|
204
|
-
const { findings } = validateContent({ text: code, file }, k);
|
|
204
|
+
const { findings, exempt } = validateContent({ text: code, file }, k);
|
|
205
|
+
if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
|
|
205
206
|
if (!findings.length) return cleanResultText();
|
|
206
207
|
const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
|
|
207
208
|
for (const f of findings.slice(0, 12)) {
|
|
@@ -238,16 +239,21 @@ export function reviewData(k) {
|
|
|
238
239
|
if (!changed.length) return { text: 'No changed UI or style files in the working tree. Nothing to review.', total: 0 };
|
|
239
240
|
|
|
240
241
|
const perFile = [];
|
|
241
|
-
let total = 0;
|
|
242
|
+
let total = 0, exemptCount = 0;
|
|
242
243
|
for (const f of changed.slice(0, 40)) {
|
|
243
244
|
let text;
|
|
244
245
|
try { text = readFileSync(join(gitRoot, f), 'utf8'); } catch { continue; }
|
|
245
246
|
// knowledge paths are scanned-root-relative; git paths are toplevel-relative
|
|
246
|
-
const { findings } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)) }, k);
|
|
247
|
+
const { findings, exempt } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)) }, k);
|
|
248
|
+
if (exempt) { exemptCount++; continue; }
|
|
247
249
|
if (findings.length) { perFile.push({ f, findings }); total += findings.length; }
|
|
248
250
|
}
|
|
251
|
+
// Skipped is not the same as clean, and the reader is owed the difference.
|
|
252
|
+
const exemptNote = exemptCount
|
|
253
|
+
? ` ${exemptCount} file${exemptCount === 1 ? ' was' : 's were'} left unjudged: email, print, artwork or pictures drawn with code.`
|
|
254
|
+
: '';
|
|
249
255
|
if (!total) {
|
|
250
|
-
return { text: `Reviewed ${changed.length} changed file${changed.length === 1 ? '' : 's'}. ${cleanResultText()}`, total: 0 };
|
|
256
|
+
return { text: `Reviewed ${changed.length} changed file${changed.length === 1 ? '' : 's'}. ${cleanResultText()}${exemptNote}`, total: 0 };
|
|
251
257
|
}
|
|
252
258
|
const L = [`DESIGN SYSTEM REVIEW · ${changed.length} changed file${changed.length === 1 ? '' : 's'}, ${total} finding${total === 1 ? '' : 's'}:`];
|
|
253
259
|
for (const { f, findings } of perFile.slice(0, 10)) {
|
|
@@ -256,7 +262,7 @@ export function reviewData(k) {
|
|
|
256
262
|
if (findings.length > 6) L.push(` (+${findings.length - 6} more in this file)`);
|
|
257
263
|
}
|
|
258
264
|
if (perFile.length > 10) L.push(`(+${perFile.length - 10} more files with findings)`);
|
|
259
|
-
L.push(`Checked: ${CHECKS.join(', ')}
|
|
265
|
+
L.push(`Checked: ${CHECKS.join(', ')}.${exemptNote} Fix the findings before finishing; rerun roast_review to confirm.`);
|
|
260
266
|
return { text: L.join('\n'), total };
|
|
261
267
|
}
|
|
262
268
|
|