roast-my-design-system 9.2.5 → 9.3.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
@@ -17,7 +17,7 @@ npx roast-my-design-system@latest
17
17
  Run it at the root of a UI repo. One second later, a report opens.
18
18
  No account. No network. No telemetry. Nothing in your repo changes.
19
19
 
20
- **Current release: 9.2.** Version history in [CHANGELOG.md](CHANGELOG.md).
20
+ **Current release: 9.3.** Version history in [CHANGELOG.md](CHANGELOG.md).
21
21
 
22
22
  ## The idea
23
23
 
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
142
142
 
143
143
  The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
144
144
 
145
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.2.4)
145
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.2.6)
146
146
 
147
147
  The same report in light mode (one file, built-in toggle):
148
148
 
149
- ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.2.4)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.2.6)
150
150
 
151
151
  ## Why the numbers hold
152
152
 
@@ -206,6 +206,7 @@ One scan powers all of it; the flags decide what lands on disk. Combine freely.
206
206
  | <code>...&nbsp;--notes&nbsp;&lt;file.md&gt;</code> | The agent's read of this scan, embedded in the report as **"What the repo teaches the agent"**: 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 |
207
207
  | <code>...&nbsp;--section&nbsp;"Title"&nbsp;&lt;file.md&gt;</code> | A further agent-written chapter after the notes, same styling, same label, sub-headings allowed. Repeatable |
208
208
  | `... --hook` | The check the Claude Code plugin runs after every edit, for hand-installed setups: reads the hook event on stdin, checks the file that changed, prints only the findings the edit added as JSON. Always exits 0 |
209
+ | `... --stop-hook` | The review the Claude Code plugin runs when the agent finishes its turn, for hand-installed setups: reads the Stop event on stdin and, when the files the session changed carry new problems, answers with a block that sends the agent back once. `--session-start` notes the work that was already uncommitted. `ROAST_STOP_REVIEW=off` turns it off. Always exits 0 |
209
210
  | `... --mcp` | The scan as a local MCP server: 5 tools your agent calls while writing UI, plus the `roast-fix` prompt that serves the top fix from a fresh scan. See [Live answers over MCP](#live-answers-over-mcp) |
210
211
  | <code>/roast-my-design-system</code> (in&nbsp;Claude&nbsp;Code) | The full experience: the roast in chat *and* embedded in the report as "What the repo teaches the agent", the rules offer, and the fix loop with Claude on your own numbers |
211
212
 
@@ -300,7 +301,7 @@ npx roast-my-design-system@latest
300
301
  /plugin install roast-my-design-system@roast-my-design-system
301
302
  ```
302
303
 
303
- 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, 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 hook runs after every file the agent edits and hands back the findings that edit added; a file with nothing new gets no message.
304
+ What it installs: two skills, one local MCP server and two checks that run on their own, nothing else. **roast** (`/roast-my-design-system`, or "roast my design system") scans the whole repo, 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 edit check runs after every file the agent edits and hands back the findings that edit added; a file with nothing new gets no message. The end-of-turn review runs when the agent says it is done: if the files this session changed still carry problems it added, the agent is sent back once to fix them before it finishes. Advice-only warnings never send it back, a problem is sent back once a session, and work that was uncommitted before the session started is left alone. To turn the review off, set `ROAST_STOP_REVIEW=off` in your Claude Code settings (`"env": { "ROAST_STOP_REVIEW": "off" }`).
304
305
 
305
306
  If those commands error, your Claude Code is older than the plugin marketplace. Update it, or use the manual route:
306
307
 
package/cli/roast.mjs CHANGED
@@ -92,6 +92,10 @@ Run it yourself
92
92
  number of files it removed, so a scoped scan says so
93
93
  --json print the scan summary as JSON on stdout (implies --no-open)
94
94
  --check check the working tree's changed files (git diff + untracked)
95
+ --stop-hook Claude Code end-of-turn review: reads the Stop event on stdin
96
+ and sends the agent back once when the files this session
97
+ changed carry new violations. ROAST_STOP_REVIEW=off turns it
98
+ off. --session-start notes the work that was already there
95
99
  --hook Claude Code edit hook: reads the PostToolUse event on stdin,
96
100
  checks the one file that changed, answers with findings
97
101
  against the design system and print the findings; exits 1
@@ -141,6 +145,24 @@ if (argv.includes('--mcp')) {
141
145
  console.error(`roast-my-design-system --hook: ${e.message}`);
142
146
  }
143
147
  process.exit(0);
148
+ } else if (argv.includes('--stop-hook') || argv.includes('--session-start')) {
149
+ // Claude Code's Stop and SessionStart hooks, same rules as --hook: read the
150
+ // event on stdin, exit 0 whatever happens. Stop answers with a block and the
151
+ // findings when the agent should go back and fix what it added.
152
+ let raw = '';
153
+ try { raw = readFileSync(0, 'utf8'); } catch { /* no stdin */ }
154
+ try {
155
+ const { stopResult, sessionStart } = await import(pathToFileURL(join(SCRIPTS, 'mcp/hook.mjs')).href);
156
+ const event = raw.trim() ? JSON.parse(raw) : {};
157
+ if (argv.includes('--session-start')) sessionStart(event);
158
+ else {
159
+ const r = stopResult(event);
160
+ if (r) console.log(JSON.stringify({ decision: 'block', reason: r.text }));
161
+ }
162
+ } catch (e) {
163
+ console.error(`roast-my-design-system ${argv.includes('--session-start') ? '--session-start' : '--stop-hook'}: ${e.message}`);
164
+ }
165
+ process.exit(0);
144
166
  } else if (argv.includes('--check')) {
145
167
  argv.splice(argv.indexOf('--check'), 1);
146
168
  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": "9.2.5",
3
+ "version": "9.3.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": [
@@ -1534,7 +1534,7 @@ function skippedLine() {
1534
1534
  const parts = [];
1535
1535
  if (skippedDirs.length) {
1536
1536
  const list = skippedDirs.slice(0, 6).map((e) => `<span class="mono">${esc(e.dir)}/</span> (${n(e.files)} UI file${e.files === 1 ? '' : 's'})`).join(', ');
1537
- parts.push(`<div class="excl">Left out by design: ${list}${skippedDirs.length > 6 ? ` and ${n(skippedDirs.length - 6)} more` : ''}. A docs site, a demo, an examples folder or a replaced app carries its own styling, which is not the product's design language.</div>`);
1537
+ parts.push(`<div class="excl">Left out by design: ${list}${skippedDirs.length > 6 ? ` and ${n(skippedDirs.length - 6)} more` : ''}. A docs site, a demo, an examples folder, a replaced app or the templates a project generator copies out carries its own styling, which is not the product's design language.</div>`);
1538
1538
  }
1539
1539
  const ra = h.files?.readAnyway;
1540
1540
  if (ra) parts.push(`<div class="excl"><span class="mono">${esc(ra.dir)}/</span> is normally left out, but it holds ${n(ra.files)} UI files, more than the rest of the repo, so it was read as the product.</div>`);
@@ -36,7 +36,7 @@ const SKIP_DIRS = new Set([
36
36
  'examples', 'example', 'demos', 'demo', 'playground', 'fixtures',
37
37
  // Test/fixture surfaces are not the product's design language — counting a
38
38
  // story's `export const Default` 36× would poison the diagnosis numbers.
39
- '__tests__', '__mocks__', '__fixtures__', '__snapshots__',
39
+ '__tests__', '__mocks__', '__fixtures__', '__testfixtures__', '__snapshots__',
40
40
  'cypress', 'e2e', 'playwright', 'test', 'tests', '.storybook',
41
41
  ]);
42
42
 
@@ -78,6 +78,15 @@ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
78
78
  // (2026-09-17). Named in the report like any other skipped folder.
79
79
  const LEGACY_DIR_RE = /^(?:old|legacy|deprecated|archive|archived)$|[-_](?:old|legacy|deprecated)$|^(?:old|legacy)[-_]/i;
80
80
 
81
+ // Scaffolding: the code a project generator copies into someone else's new
82
+ // repo, not the product. A starters/ folder, or a templates folder kept by a
83
+ // generator (create-app, a cli), a script or a dev tool: backstage's
84
+ // create-app/templates, the dashboard starter's scripts/cleanup-templates,
85
+ // BuilderIO's starters (133 duplicate copies). The name alone is not enough:
86
+ // backstage's scaffolder form templates, OpenCTI's case templates and
87
+ // nocobase's block templates are screens of the product (2026-09-30).
88
+ const SCAFFOLD_DIR_RE = /(^|\/)starters$|(^|\/)(?:create-[\w-]+|cli|scripts|dev|generators?)\/(?:[^/]+\/)*[\w-]*templates?$/i;
89
+
81
90
  export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = new Set()) {
82
91
  const files = { code: [], styles: [], other: [] };
83
92
  const excluded = exclusions?.match ?? (() => null);
@@ -135,6 +144,7 @@ export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = ne
135
144
  const p = join(dir, e.name);
136
145
  if (e.name === 'public' && e.isDirectory() && !hasComponentSource(p)) continue;
137
146
  const rel = relative(root, p).replaceAll('\\', '/');
147
+ if (e.isDirectory() && SCAFFOLD_DIR_RE.test(rel)) { countSkipped(p, rel); continue; }
138
148
  const hit = excluded(rel);
139
149
  if (hit) {
140
150
  if (e.isDirectory()) countExcluded(p, depth + 1, hit);
@@ -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 = '9.2.5';
4
+ export const VERSION = '9.3.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.
@@ -83,6 +83,12 @@ export function hookResult(payload) {
83
83
  // (five repeats in one Dub rehearsal, 2026-09-25). Violations repeat
84
84
  // until they are fixed.
85
85
  const warned = new Set(ledger['\u0000warned'] ?? []);
86
+ // the files this session wrote, for the review at the end of the turn: a
87
+ // file named by an edit, or a file a shell command changed after the
88
+ // session began (uncommitted work already there is the person's, not the
89
+ // agent's)
90
+ const touched = new Set(ledger['\u0000touched'] ?? []);
91
+ const base = ledger['\u0000base'] ?? {};
86
92
  const blocks = [];
87
93
  let total = 0, knowledge = null;
88
94
  for (const abs of candidates) {
@@ -91,6 +97,7 @@ export function hookResult(payload) {
91
97
  const mtime = statSync(real).mtimeMs;
92
98
  if (!fromEditor && ledger[real] === mtime) continue;
93
99
  ledger[real] = mtime;
100
+ if (fromEditor || base[real] !== mtime) touched.add(real);
94
101
  // The repo is the session's working directory when the file lives under
95
102
  // it (the same root the MCP server would be pointed at); a file edited
96
103
  // elsewhere is judged against its own repository.
@@ -102,6 +109,7 @@ export function hookResult(payload) {
102
109
  if (r) { blocks.push(r.text); total += r.total; }
103
110
  }
104
111
  ledger['\u0000warned'] = [...warned];
112
+ ledger['\u0000touched'] = [...touched];
105
113
  writeLedger(session, ledger);
106
114
  if (!blocks.length) return null;
107
115
  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.');
@@ -109,7 +117,7 @@ export function hookResult(payload) {
109
117
  }
110
118
 
111
119
  /** One file against the system: only what the working copy added since HEAD. */
112
- function judge(real, root, k, warned = new Set()) {
120
+ function judge(real, root, k, warned = new Set(), { violationsOnly = false } = {}) {
113
121
  const rel = relative(realpathSync.native(root), real).split('\\').join('/');
114
122
  const text = readFileSync(real, 'utf8');
115
123
  const before = beforeOf(k, rel);
@@ -124,11 +132,89 @@ function judge(real, root, k, warned = new Set()) {
124
132
  const fresh = typeof before === 'string' ? newSince(all, validateContent({ text: before, file: rel }, k).findings) : all;
125
133
  const findings = fresh.filter((f) => {
126
134
  if (f.severity !== 'warning') return true;
135
+ if (violationsOnly) return false;
127
136
  const key = `${rel}|${f.rule}|${f.message}`;
128
137
  if (warned.has(key)) return false;
129
138
  warned.add(key);
130
139
  return true;
131
140
  });
132
141
  if (!findings.length) return null;
142
+ if (violationsOnly) return { rel, findings, exempt, total: findings.length };
133
143
  return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k, exempt).join('\n')}`, total: findings.length };
134
144
  }
145
+
146
+ /**
147
+ * SessionStart: note the uncommitted UI files that were already there, so the
148
+ * review at the end of a turn never hands the agent someone else's work.
149
+ * Kept for the whole session: a resumed or compacted session keeps its start.
150
+ */
151
+ export function sessionStart(payload) {
152
+ const session = payload?.session_id;
153
+ if (!session) return;
154
+ const ledger = readLedger(session);
155
+ if (ledger['\u0000base']) return;
156
+ const cwd = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
157
+ const base = {};
158
+ for (const abs of changedFiles(cwd)) {
159
+ try { const real = realpathSync.native(abs); base[real] = statSync(real).mtimeMs; } catch { /* gone */ }
160
+ }
161
+ ledger['\u0000base'] = base;
162
+ writeLedger(session, ledger);
163
+ }
164
+
165
+ // Anything but these leaves the review on: it exists so that nobody has to
166
+ // remember to ask for it.
167
+ const OFF = /^(off|0|false|no)$/i;
168
+
169
+ /**
170
+ * Stop: the review at the end of the agent's turn. The edit hook tells the
171
+ * agent about drift after each edit, and the agent may carry on regardless
172
+ * (Haiku still shipped it in 1 of 5 runs with the edit hook on, September
173
+ * 2026). This judges every UI file the session changed, keeps the violations
174
+ * that are new since HEAD, and when there are any sends the agent back once to
175
+ * fix them. Warnings never send it back. A second stop in a row always goes
176
+ * through: one extra round, never a loop.
177
+ * @param payload the Stop event Claude Code writes to stdin
178
+ * @returns { text, total } when the agent should go back, else null
179
+ */
180
+ export function stopResult(payload, env = process.env) {
181
+ if (OFF.test(String(env.ROAST_STOP_REVIEW ?? ''))) return null;
182
+ if (payload?.stop_hook_active) return null;
183
+ const session = payload?.session_id;
184
+ if (!session) return null;
185
+ const ledger = readLedger(session);
186
+ const touched = ledger['\u0000touched'] ?? [];
187
+ if (!touched.length) return null;
188
+ const cwd = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
189
+ // A problem is sent back once a session. What the agent kept after that
190
+ // (a deliberate value, commented) is its call, not a loop across turns.
191
+ const sent = new Set(ledger['\u0000sentback'] ?? []);
192
+ const results = [];
193
+ let total = 0, knowledge = null;
194
+ for (const real of touched) {
195
+ if (!existsSync(real)) continue;
196
+ const under = (root) => root && real.startsWith(realpathSync.native(root) + '/');
197
+ const root = under(cwd) ? cwd : gitTop(dirname(real));
198
+ if (!root) continue;
199
+ const k = knowledge?.root === root ? knowledge : (knowledge = loadKnowledge(root));
200
+ const r = judge(real, root, k, new Set(), { violationsOnly: true });
201
+ if (!r) continue;
202
+ const findings = r.findings.filter((f) => !sent.has(`${r.rel}|${f.rule}|${f.message}`));
203
+ if (!findings.length) continue;
204
+ for (const f of findings) sent.add(`${r.rel}|${f.rule}|${f.message}`);
205
+ results.push({ ...r, findings, total: findings.length });
206
+ total += findings.length;
207
+ }
208
+ if (!total) return null;
209
+ ledger['\u0000sentback'] = [...sent];
210
+ writeLedger(session, ledger);
211
+ const L = [`Before you finish: the design-system review (roast-my-design-system) found ${total} problem${total === 1 ? '' : 's'} in what this session added to ${results.length} file${results.length === 1 ? '' : 's'}.`];
212
+ for (const r of results.slice(0, 10)) {
213
+ L.push(`${r.rel}:`);
214
+ for (const f of r.findings.slice(0, 8)) L.push(` ✕ L${f.line} ${f.message}${f.fix ? `\n Fix: ${f.fix}` : ''}`);
215
+ if (r.findings.length > 8) L.push(` (+${r.findings.length - 8} more in this file)`);
216
+ }
217
+ if (results.length > 10) L.push(`(+${results.length - 10} more files)`);
218
+ L.push('Fix these in the repo\'s own vocabulary, as each fix says (a token, a spacing step, a theme path, the existing component), then finish. A value that is deliberate stays, with a one-line comment saying why; say so in your reply.');
219
+ return { text: L.join('\n'), total };
220
+ }