nodebench-mcp 2.28.0 → 2.30.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.
@@ -0,0 +1,872 @@
1
+ /**
2
+ * Design Governance Tools — Automated design spec enforcement for MCP agents.
3
+ *
4
+ * Provides three tools for agents to query the design system spec, check
5
+ * individual files for compliance, and scan an entire src/ tree for violations.
6
+ *
7
+ * Patterns are inlined (not imported from the frontend src/ directory) because
8
+ * this is a CommonJS-ish Node.js MCP server that can't reach into the
9
+ * frontend build. The canonical source of truth is:
10
+ * - src/design-governance/defaultSpec.ts
11
+ * - scripts/ui/designLinter.mjs
12
+ * Keep this file in sync when updating banned patterns.
13
+ *
14
+ * 3 tools:
15
+ * - get_design_spec: Return the full governance spec as structured JSON
16
+ * - check_design_compliance: Lint a single file for violations
17
+ * - get_design_violations: Scan entire src/ tree, grouped + sorted
18
+ */
19
+ import { readFile, readdir } from "node:fs/promises";
20
+ import * as path from "node:path";
21
+ // ── Config Excludes ─────────────────────────────────────────────────────────
22
+ const CONFIG_EXCLUDES = [
23
+ "index.css",
24
+ "tailwind.config",
25
+ ".stories.",
26
+ ".test.",
27
+ ".spec.",
28
+ "__tests__",
29
+ "design-governance",
30
+ "node_modules",
31
+ ];
32
+ // ── Banned Patterns (inlined) ───────────────────────────────────────────────
33
+ const BANNED_PATTERNS = [
34
+ // ── Color: saturated backgrounds ──
35
+ {
36
+ pattern: /\bbg-(red|orange|amber|yellow|green|blue|indigo|violet|purple|pink|emerald|teal|cyan|lime|rose|fuchsia|sky)-(50|100|200|300|400|500|600|700|800|900|950)\b/g,
37
+ label: "Saturated bg color (use bg-surface variants)",
38
+ severity: "medium",
39
+ category: "color",
40
+ fix: "Replace with bg-surface, bg-surface-secondary, or bg-surface-hover",
41
+ excludeFiles: CONFIG_EXCLUDES,
42
+ },
43
+ // ── Color: saturated text ──
44
+ {
45
+ pattern: /\btext-(red|orange|amber|yellow|green|blue|indigo|violet|purple|pink|emerald|teal|cyan|lime|rose|fuchsia|sky)-(50|100|200|300|400|500|600|700|800|900|950)\b/g,
46
+ label: "Saturated text color (use text-content variants)",
47
+ severity: "medium",
48
+ category: "color",
49
+ fix: "Replace with text-content, text-content-secondary, or text-content-muted",
50
+ excludeFiles: CONFIG_EXCLUDES,
51
+ },
52
+ // ── Color: raw gray bg ──
53
+ {
54
+ pattern: /\bbg-gray-(50|100|200|300|400|500|600|700|800|900|950)\b/g,
55
+ label: "bg-gray-* (use bg-surface variants)",
56
+ severity: "medium",
57
+ category: "color",
58
+ fix: "bg-gray-50/100 -> bg-surface-secondary, bg-gray-200 -> bg-surface-hover",
59
+ excludeFiles: CONFIG_EXCLUDES,
60
+ },
61
+ // ── Color: raw gray text ──
62
+ {
63
+ pattern: /\btext-gray-(50|100|200|300|400|500|600|700|800|900|950)\b/g,
64
+ label: "text-gray-* (use text-content variants)",
65
+ severity: "medium",
66
+ category: "color",
67
+ fix: "text-gray-500/600 -> text-content-muted, text-gray-700/800 -> text-content-secondary",
68
+ excludeFiles: CONFIG_EXCLUDES,
69
+ },
70
+ // ── Color: raw gray border ──
71
+ {
72
+ pattern: /\bborder-gray-(50|100|200|300|400|500|600|700|800|900|950)\b/g,
73
+ label: "border-gray-* (use border-edge)",
74
+ severity: "medium",
75
+ category: "color",
76
+ fix: "Replace with border-edge",
77
+ excludeFiles: CONFIG_EXCLUDES,
78
+ },
79
+ // ── Color: decorative gradients ──
80
+ {
81
+ pattern: /\bfrom-(amber|orange|purple|pink|indigo|red|green|blue)-(50|100|200)\b/g,
82
+ label: "Decorative gradient (use flat surface colors)",
83
+ severity: "medium",
84
+ category: "color",
85
+ fix: "Remove gradient, use bg-surface or bg-surface-secondary",
86
+ excludeFiles: CONFIG_EXCLUDES,
87
+ },
88
+ // ── Color: non-semantic bg-white ──
89
+ {
90
+ pattern: /\bbg-white\b(?!\/)/g,
91
+ label: "bg-white (use bg-surface)",
92
+ severity: "low",
93
+ category: "color",
94
+ fix: "Replace with bg-surface",
95
+ excludeFiles: CONFIG_EXCLUDES,
96
+ },
97
+ // ── Color: hardcoded hex #f2f1ed ──
98
+ {
99
+ pattern: /#f2f1ed/gi,
100
+ label: "Hardcoded hex #f2f1ed",
101
+ severity: "high",
102
+ category: "color",
103
+ fix: "Use CSS variable or semantic token",
104
+ excludeFiles: CONFIG_EXCLUDES,
105
+ },
106
+ // ── Focus: hardcoded indigo rings ──
107
+ {
108
+ pattern: /\bring-indigo-[0-9]+\b/g,
109
+ label: "Hardcoded indigo focus ring (use ring-ring)",
110
+ severity: "medium",
111
+ category: "focus",
112
+ fix: "Replace ring-indigo-500 with ring-ring (maps to --ring CSS var)",
113
+ excludeFiles: CONFIG_EXCLUDES,
114
+ },
115
+ {
116
+ pattern: /focus-visible:ring-indigo/g,
117
+ label: "Focus ring using hardcoded indigo",
118
+ severity: "medium",
119
+ category: "focus",
120
+ fix: "Use focus-visible:ring-ring",
121
+ excludeFiles: CONFIG_EXCLUDES,
122
+ },
123
+ // ── Typography: ALL CAPS ──
124
+ {
125
+ pattern: /uppercase\s+tracking-widest/g,
126
+ label: "ALL CAPS tracking-widest (use .type-label)",
127
+ severity: "high",
128
+ category: "typography",
129
+ fix: "Replace with className='type-label'",
130
+ excludeFiles: CONFIG_EXCLUDES,
131
+ },
132
+ // ── Typography: custom tracking ──
133
+ {
134
+ pattern: /tracking-\[0\.\d+em\]/g,
135
+ label: "Custom tracking value (use type scale)",
136
+ severity: "medium",
137
+ category: "typography",
138
+ fix: "Use .type-label (includes tracking-wider) or remove",
139
+ excludeFiles: CONFIG_EXCLUDES,
140
+ },
141
+ // ── Typography: font-black ──
142
+ {
143
+ pattern: /\bfont-black\b/g,
144
+ label: "font-black weight (max: font-bold)",
145
+ severity: "low",
146
+ category: "typography",
147
+ fix: "Replace with font-bold or font-semibold",
148
+ excludeFiles: CONFIG_EXCLUDES,
149
+ },
150
+ // ── Button: inline styling ──
151
+ {
152
+ pattern: /bg-indigo-600\s+text-white\s+.*rounded/g,
153
+ label: "Inline button styling (use .btn-primary-sm)",
154
+ severity: "low",
155
+ category: "button",
156
+ fix: "Replace inline styles with btn-primary-sm class",
157
+ excludeFiles: CONFIG_EXCLUDES,
158
+ },
159
+ ];
160
+ // ── File-ignore patterns for directory walk ─────────────────────────────────
161
+ const FILE_IGNORE_PATTERNS = [
162
+ /node_modules/,
163
+ /\.test\.(ts|tsx)$/,
164
+ /\.spec\.(ts|tsx)$/,
165
+ /\.config\./,
166
+ /tailwind\./,
167
+ /__tests__/,
168
+ /\.stories\./,
169
+ /\.d\.ts$/,
170
+ /design-governance/,
171
+ /index\.css$/,
172
+ ];
173
+ const DIR_SKIP = new Set([
174
+ "node_modules",
175
+ ".git",
176
+ "dist",
177
+ "__tests__",
178
+ ".next",
179
+ ".turbo",
180
+ ]);
181
+ // ── The Full Design Spec (inlined) ──────────────────────────────────────────
182
+ const DESIGN_SPEC = {
183
+ version: "1.0.0",
184
+ lastUpdated: "2026-02-24",
185
+ colorBudget: {
186
+ maxDistinctPerRoute: 6,
187
+ approvedSemanticColors: [
188
+ "surface",
189
+ "surface-secondary",
190
+ "surface-hover",
191
+ "content",
192
+ "content-secondary",
193
+ "content-muted",
194
+ "edge",
195
+ "primary",
196
+ "primary-foreground",
197
+ "secondary",
198
+ "destructive",
199
+ "muted",
200
+ "muted-foreground",
201
+ "accent",
202
+ "background",
203
+ "foreground",
204
+ "card",
205
+ "popover",
206
+ "ring",
207
+ "input",
208
+ "border",
209
+ ],
210
+ accentFamily: "indigo",
211
+ approvedAccentShades: [500, 600, 700],
212
+ statusColors: {
213
+ success: ["text-green-600", "dark:text-green-400"],
214
+ warning: ["text-amber-600", "dark:text-amber-400"],
215
+ error: ["text-red-600", "dark:text-red-400"],
216
+ info: ["text-blue-600", "dark:text-blue-400"],
217
+ },
218
+ },
219
+ typography: {
220
+ contextClasses: {
221
+ "page-title": ["type-page-title"],
222
+ "section-heading": ["type-section-title"],
223
+ "card-title": ["type-card-title"],
224
+ body: ["type-body"],
225
+ caption: ["type-caption"],
226
+ label: ["type-label"],
227
+ },
228
+ fontFamilies: {
229
+ ui: "Inter",
230
+ code: "JetBrains Mono",
231
+ },
232
+ baseFontSize: "14px",
233
+ uppercasePolicy: {
234
+ allowedClasses: ["type-label"],
235
+ allowedContexts: [
236
+ "sidebar section labels (MENU, MORE, FILES)",
237
+ "table column headers (<th>)",
238
+ ],
239
+ description: "Uppercase is only allowed in .type-label class (sidebar section labels) and table column headers. All section headings, card titles, stat labels, and badge text must be sentence case.",
240
+ },
241
+ },
242
+ spacing: {
243
+ approvedScale: [
244
+ 0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16,
245
+ 20, 24,
246
+ ],
247
+ sectionGap: "space-y-8",
248
+ cardPadding: "p-4 sm:p-6",
249
+ pageInnerPadding: "px-6 sm:px-8 lg:px-10 py-8",
250
+ },
251
+ components: {
252
+ layoutPrimitives: {
253
+ pageShell: "nb-page-shell",
254
+ pageInner: "nb-page-inner",
255
+ pageFrame: "nb-page-frame",
256
+ pageFrameNarrow: "nb-page-frame-narrow",
257
+ surfaceCard: "nb-surface-card",
258
+ },
259
+ buttonClasses: [
260
+ "btn-primary-xs",
261
+ "btn-primary-sm",
262
+ "btn-ghost-sm",
263
+ "btn-outline-sm",
264
+ ],
265
+ focusRing: "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
266
+ },
267
+ states: {
268
+ requiredStates: ["loading", "empty", "error"],
269
+ loadingComponents: ["ViewSkeleton", "SignatureOrb", "Skeleton"],
270
+ emptyStateComponents: ["EmptyState", "SignatureOrb"],
271
+ },
272
+ motion: {
273
+ maxConcurrentAnimations: 3,
274
+ maxTransitionDuration: "500ms",
275
+ reducedMotionRequired: true,
276
+ },
277
+ borderRadius: {
278
+ card: "rounded-lg",
279
+ button: "rounded-md",
280
+ input: "rounded-md",
281
+ container: "rounded-xl",
282
+ },
283
+ bannedPatterns: BANNED_PATTERNS.map((bp) => ({
284
+ label: bp.label,
285
+ severity: bp.severity,
286
+ category: bp.category,
287
+ fix: bp.fix,
288
+ pattern: bp.pattern.source,
289
+ })),
290
+ };
291
+ // ── Structural checks (view files) ─────────────────────────────────────────
292
+ function getStructuralViolations(content, relPath) {
293
+ const violations = [];
294
+ const isView = /views\/.*\.tsx$/.test(relPath);
295
+ if (!isView)
296
+ return violations;
297
+ // Check for nb-page-shell usage
298
+ if (!content.includes("nb-page-shell") && !content.includes("PageShell")) {
299
+ violations.push({
300
+ file: relPath,
301
+ line: 1,
302
+ match: "(file-level)",
303
+ label: "View missing nb-page-shell layout primitive",
304
+ severity: "high",
305
+ category: "layout",
306
+ fix: "Wrap view content in <div className='nb-page-shell'>",
307
+ });
308
+ }
309
+ // Check for empty state handling
310
+ const hasEmptyState = /EmptyState|empty.?state|SignatureOrb.*empty|no.?data|no.?items|no.?results|\.length\s*===?\s*0/i.test(content);
311
+ if (!hasEmptyState) {
312
+ violations.push({
313
+ file: relPath,
314
+ line: 1,
315
+ match: "(file-level)",
316
+ label: "View possibly missing empty state handling",
317
+ severity: "medium",
318
+ category: "states",
319
+ fix: "Add EmptyState or SignatureOrb variant='empty' for when data is empty",
320
+ });
321
+ }
322
+ // Check for loading state
323
+ const hasLoading = /Skeleton|loading|isLoading|SignatureOrb.*loading|Suspense/i.test(content);
324
+ if (!hasLoading) {
325
+ violations.push({
326
+ file: relPath,
327
+ line: 1,
328
+ match: "(file-level)",
329
+ label: "View possibly missing loading state",
330
+ severity: "medium",
331
+ category: "states",
332
+ fix: "Add ViewSkeleton or SignatureOrb variant='loading' for loading state",
333
+ });
334
+ }
335
+ return violations;
336
+ }
337
+ // ── Pattern scanning for a single file ──────────────────────────────────────
338
+ function scanFileContent(content, relPath) {
339
+ const violations = [];
340
+ const lines = content.split("\n");
341
+ for (const bp of BANNED_PATTERNS) {
342
+ // Check per-pattern excludeFiles
343
+ if (bp.excludeFiles.some((exc) => relPath.includes(exc)))
344
+ continue;
345
+ for (let i = 0; i < lines.length; i++) {
346
+ // Reset lastIndex for each line (regex is /g)
347
+ bp.pattern.lastIndex = 0;
348
+ let match;
349
+ while ((match = bp.pattern.exec(lines[i])) !== null) {
350
+ violations.push({
351
+ file: relPath,
352
+ line: i + 1,
353
+ match: match[0],
354
+ label: bp.label,
355
+ severity: bp.severity,
356
+ category: bp.category,
357
+ fix: bp.fix,
358
+ });
359
+ }
360
+ }
361
+ }
362
+ // Structural violations
363
+ const structural = getStructuralViolations(content, relPath);
364
+ violations.push(...structural);
365
+ return violations;
366
+ }
367
+ // ── Recursive directory walker ──────────────────────────────────────────────
368
+ async function walkSrcDir(dir, rootDir) {
369
+ const files = [];
370
+ let entries;
371
+ try {
372
+ entries = await readdir(dir, { withFileTypes: true });
373
+ }
374
+ catch {
375
+ return files;
376
+ }
377
+ for (const entry of entries) {
378
+ const full = path.join(dir, entry.name);
379
+ if (entry.isDirectory()) {
380
+ if (DIR_SKIP.has(entry.name))
381
+ continue;
382
+ const sub = await walkSrcDir(full, rootDir);
383
+ files.push(...sub);
384
+ continue;
385
+ }
386
+ // Only .tsx, .ts, .jsx, .js
387
+ if (!/\.(tsx?|jsx?)$/.test(entry.name))
388
+ continue;
389
+ const relPath = path.relative(rootDir, full).replace(/\\/g, "/");
390
+ if (FILE_IGNORE_PATTERNS.some((p) => p.test(relPath)))
391
+ continue;
392
+ files.push(full);
393
+ }
394
+ return files;
395
+ }
396
+ // ── Severity ordering helper ────────────────────────────────────────────────
397
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
398
+ function sortViolations(violations) {
399
+ return violations.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
400
+ a.file.localeCompare(b.file) ||
401
+ a.line - b.line);
402
+ }
403
+ // ── Scoring helper ──────────────────────────────────────────────────────────
404
+ function computeScore(violations) {
405
+ let deductions = 0;
406
+ for (const v of violations) {
407
+ if (v.severity === "high")
408
+ deductions += 10;
409
+ else if (v.severity === "medium")
410
+ deductions += 3;
411
+ else
412
+ deductions += 1;
413
+ }
414
+ return Math.max(0, 100 - deductions);
415
+ }
416
+ function computeStats(violations) {
417
+ const high = violations.filter((v) => v.severity === "high").length;
418
+ const medium = violations.filter((v) => v.severity === "medium").length;
419
+ const low = violations.filter((v) => v.severity === "low").length;
420
+ const byCategory = {};
421
+ for (const v of violations) {
422
+ byCategory[v.category] = (byCategory[v.category] || 0) + 1;
423
+ }
424
+ return { high, medium, low, byCategory, score: computeScore(violations) };
425
+ }
426
+ // ── Tools ───────────────────────────────────────────────────────────────────
427
+ export const designGovernanceTools = [
428
+ // ─────────────────────────────────────────────────────────────────────────
429
+ // Tool 1: get_design_spec
430
+ // ─────────────────────────────────────────────────────────────────────────
431
+ {
432
+ name: "get_design_spec",
433
+ description: "Return the full design governance specification as structured JSON. Includes: approved semantic colors, typography classes and uppercase policy, component layout primitives, spacing scale, button classes, focus ring pattern, motion rules, border radius rules, and all banned patterns with fix suggestions. Use this before writing or modifying UI code to ensure compliance.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ section: {
438
+ type: "string",
439
+ enum: [
440
+ "all",
441
+ "colorBudget",
442
+ "typography",
443
+ "spacing",
444
+ "components",
445
+ "states",
446
+ "motion",
447
+ "borderRadius",
448
+ "bannedPatterns",
449
+ ],
450
+ description: "Which section to return (default 'all'). Use a specific section to reduce token usage.",
451
+ },
452
+ },
453
+ required: [],
454
+ },
455
+ handler: async (args) => {
456
+ const section = args.section || "all";
457
+ if (section === "all") {
458
+ return DESIGN_SPEC;
459
+ }
460
+ const sectionData = DESIGN_SPEC[section];
461
+ if (!sectionData) {
462
+ return {
463
+ error: `Unknown section '${section}'`,
464
+ availableSections: Object.keys(DESIGN_SPEC),
465
+ };
466
+ }
467
+ return { section, specVersion: DESIGN_SPEC.version, data: sectionData };
468
+ },
469
+ },
470
+ // ─────────────────────────────────────────────────────────────────────────
471
+ // Tool 2: check_design_compliance
472
+ // ─────────────────────────────────────────────────────────────────────────
473
+ {
474
+ name: "check_design_compliance",
475
+ description: "Check a single .tsx/.ts file for design governance compliance. Runs all banned pattern checks (color, typography, focus, button) plus structural checks for view files (nb-page-shell, empty state, loading state). Returns violations array with line numbers, severity, fix suggestions, and a pass/fail summary. Use after editing UI files to verify compliance.",
476
+ inputSchema: {
477
+ type: "object",
478
+ properties: {
479
+ filePath: {
480
+ type: "string",
481
+ description: "Path to the .tsx/.ts file to check (absolute or relative to cwd)",
482
+ },
483
+ },
484
+ required: ["filePath"],
485
+ },
486
+ handler: async (args) => {
487
+ const resolved = path.isAbsolute(args.filePath)
488
+ ? args.filePath
489
+ : path.resolve(process.cwd(), args.filePath);
490
+ let content;
491
+ try {
492
+ content = await readFile(resolved, "utf-8");
493
+ }
494
+ catch (err) {
495
+ const msg = err instanceof Error ? err.message : "Failed to read file";
496
+ return { error: msg, filePath: resolved, pass: false };
497
+ }
498
+ // Compute relative path for pattern matching (file-ignore logic uses relative paths)
499
+ const cwd = process.cwd();
500
+ const srcDir = path.join(cwd, "src");
501
+ const relPath = resolved.startsWith(srcDir)
502
+ ? path.relative(cwd, resolved).replace(/\\/g, "/")
503
+ : path.basename(resolved);
504
+ const violations = scanFileContent(content, relPath);
505
+ const sorted = sortViolations(violations);
506
+ const stats = computeStats(sorted);
507
+ const pass = stats.high === 0;
508
+ return {
509
+ filePath: resolved,
510
+ relativePath: relPath,
511
+ specVersion: DESIGN_SPEC.version,
512
+ pass,
513
+ summary: pass
514
+ ? stats.medium + stats.low === 0
515
+ ? "Fully compliant — no violations found."
516
+ : `Conditionally passing — ${stats.medium} medium, ${stats.low} low violations (no high).`
517
+ : `FAIL — ${stats.high} high-severity violation(s) must be fixed.`,
518
+ stats: {
519
+ total: sorted.length,
520
+ high: stats.high,
521
+ medium: stats.medium,
522
+ low: stats.low,
523
+ byCategory: stats.byCategory,
524
+ score: stats.score,
525
+ },
526
+ violations: sorted,
527
+ };
528
+ },
529
+ },
530
+ // ─────────────────────────────────────────────────────────────────────────
531
+ // Tool 3: get_design_violations
532
+ // ─────────────────────────────────────────────────────────────────────────
533
+ {
534
+ name: "get_design_violations",
535
+ description: "Scan the entire src/ directory for design governance violations. Groups results by file, sorted by severity. Supports filtering by severity (high/medium/low/all) and category (color/typography/layout/states/focus/button/all). Returns summary stats, compliance score (0-100), and top violations up to the limit. Use for project-wide design audits.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ severity: {
540
+ type: "string",
541
+ enum: ["high", "medium", "low", "all"],
542
+ description: "Filter by severity level (default 'all')",
543
+ },
544
+ category: {
545
+ type: "string",
546
+ enum: [
547
+ "color",
548
+ "typography",
549
+ "layout",
550
+ "states",
551
+ "focus",
552
+ "button",
553
+ "all",
554
+ ],
555
+ description: "Filter by violation category (default 'all')",
556
+ },
557
+ limit: {
558
+ type: "number",
559
+ description: "Max violations to return (default 50)",
560
+ },
561
+ },
562
+ required: [],
563
+ },
564
+ handler: async (args) => {
565
+ const severityFilter = args.severity || "all";
566
+ const categoryFilter = args.category || "all";
567
+ const limit = args.limit ?? 50;
568
+ // Locate src/ relative to cwd
569
+ const cwd = process.cwd();
570
+ const srcDir = path.join(cwd, "src");
571
+ let allFiles;
572
+ try {
573
+ allFiles = await walkSrcDir(srcDir, cwd);
574
+ }
575
+ catch (err) {
576
+ const msg = err instanceof Error ? err.message : "Failed to walk src/";
577
+ return { error: msg, srcDir, hint: "Ensure src/ directory exists at project root" };
578
+ }
579
+ if (allFiles.length === 0) {
580
+ return {
581
+ error: "No scannable files found in src/",
582
+ srcDir,
583
+ hint: "src/ may be empty or all files are excluded by ignore patterns",
584
+ };
585
+ }
586
+ // Scan all files
587
+ let allViolations = [];
588
+ for (const filePath of allFiles) {
589
+ const relPath = path.relative(cwd, filePath).replace(/\\/g, "/");
590
+ let content;
591
+ try {
592
+ content = await readFile(filePath, "utf-8");
593
+ }
594
+ catch {
595
+ continue;
596
+ }
597
+ const violations = scanFileContent(content, relPath);
598
+ allViolations.push(...violations);
599
+ }
600
+ // Apply filters
601
+ if (severityFilter !== "all") {
602
+ allViolations = allViolations.filter((v) => v.severity === severityFilter);
603
+ }
604
+ if (categoryFilter !== "all") {
605
+ allViolations = allViolations.filter((v) => v.category === categoryFilter);
606
+ }
607
+ const sorted = sortViolations(allViolations);
608
+ // Compute full stats BEFORE limiting
609
+ const fullStats = computeStats(sorted);
610
+ // Group by file
611
+ const byFile = {};
612
+ for (const v of sorted) {
613
+ if (!byFile[v.file])
614
+ byFile[v.file] = [];
615
+ byFile[v.file].push(v);
616
+ }
617
+ // Top files by violation count
618
+ const topFiles = Object.entries(byFile)
619
+ .sort((a, b) => b[1].length - a[1].length)
620
+ .map(([file, violations]) => {
621
+ const fileHigh = violations.filter((v) => v.severity === "high").length;
622
+ const fileMed = violations.filter((v) => v.severity === "medium").length;
623
+ const fileLow = violations.filter((v) => v.severity === "low").length;
624
+ return {
625
+ file,
626
+ total: violations.length,
627
+ high: fileHigh,
628
+ medium: fileMed,
629
+ low: fileLow,
630
+ };
631
+ });
632
+ // Apply limit to individual violations
633
+ const limited = sorted.slice(0, limit);
634
+ return {
635
+ specVersion: DESIGN_SPEC.version,
636
+ srcDir,
637
+ filesScanned: allFiles.length,
638
+ totalViolations: sorted.length,
639
+ violationsReturned: limited.length,
640
+ truncated: sorted.length > limit,
641
+ filters: {
642
+ severity: severityFilter,
643
+ category: categoryFilter,
644
+ limit,
645
+ },
646
+ stats: {
647
+ high: fullStats.high,
648
+ medium: fullStats.medium,
649
+ low: fullStats.low,
650
+ byCategory: fullStats.byCategory,
651
+ score: fullStats.score,
652
+ },
653
+ pass: fullStats.high === 0,
654
+ topFilesBySeverity: topFiles.slice(0, 20),
655
+ violations: limited,
656
+ };
657
+ },
658
+ },
659
+ // ── sync_figma_tokens ─────────────────────────────────────────────────
660
+ {
661
+ name: "sync_figma_tokens",
662
+ description: "Pull Figma design token variables from a Figma file and compare against the codebase's CSS custom properties (src/index.css). Reports drift: tokens in code but not Figma, tokens in Figma but not code, and value mismatches. Requires FIGMA_ACCESS_TOKEN env var.",
663
+ inputSchema: {
664
+ type: "object",
665
+ properties: {
666
+ fileKey: {
667
+ type: "string",
668
+ description: "Figma file key (from URL: figma.com/design/<fileKey>/...). If omitted, uses FIGMA_DESIGN_SYSTEM_FILE env var.",
669
+ },
670
+ },
671
+ },
672
+ handler: async (args) => {
673
+ const fileKey = args.fileKey || process.env.FIGMA_DESIGN_SYSTEM_FILE;
674
+ const token = process.env.FIGMA_ACCESS_TOKEN;
675
+ if (!token) {
676
+ return {
677
+ error: true,
678
+ message: "FIGMA_ACCESS_TOKEN not set. Add to .env.local or set as environment variable.",
679
+ setup: "Get a personal access token from Figma > Settings > Personal access tokens.",
680
+ };
681
+ }
682
+ if (!fileKey) {
683
+ return {
684
+ error: true,
685
+ message: "No Figma file key. Provide fileKey parameter or set FIGMA_DESIGN_SYSTEM_FILE env var.",
686
+ };
687
+ }
688
+ // Extract CSS tokens from src/index.css
689
+ const srcDir = path.resolve(process.cwd(), "src");
690
+ const cssPath = path.join(srcDir, "index.css");
691
+ let cssContent;
692
+ try {
693
+ cssContent = await readFile(cssPath, "utf8");
694
+ }
695
+ catch {
696
+ return { error: true, message: `Cannot read ${cssPath}` };
697
+ }
698
+ const codeTokens = { light: {}, dark: {} };
699
+ const varRegex = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+)/g;
700
+ const rootMatch = cssContent.match(/:root\s*\{([^}]+)\}/s);
701
+ if (rootMatch) {
702
+ let m;
703
+ while ((m = varRegex.exec(rootMatch[1])) !== null) {
704
+ codeTokens.light[m[1]] = m[2].trim();
705
+ }
706
+ }
707
+ const darkMatch = cssContent.match(/\.dark\s*\{([^}]+)\}/s);
708
+ if (darkMatch) {
709
+ varRegex.lastIndex = 0;
710
+ let m;
711
+ while ((m = varRegex.exec(darkMatch[1])) !== null) {
712
+ codeTokens.dark[m[1]] = m[2].trim();
713
+ }
714
+ }
715
+ // Call Figma Variables API
716
+ try {
717
+ const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
718
+ const res = await fetch(url, {
719
+ headers: { "X-Figma-Token": token },
720
+ });
721
+ if (!res.ok) {
722
+ const text = await res.text();
723
+ return { error: true, message: `Figma API ${res.status}: ${text.slice(0, 200)}` };
724
+ }
725
+ const json = await res.json();
726
+ const variables = json.meta?.variables ?? {};
727
+ const collections = json.meta?.variableCollections ?? {};
728
+ const figmaTokens = { light: {}, dark: {} };
729
+ for (const v of Object.values(variables)) {
730
+ const name = v.name?.replace(/\//g, "-").toLowerCase() ?? "";
731
+ if (!name)
732
+ continue;
733
+ const collection = collections[v.variableCollectionId];
734
+ const modes = collection?.modes ?? [];
735
+ for (const mode of modes) {
736
+ const modeName = mode.name?.toLowerCase() ?? "";
737
+ const value = v.valuesByMode?.[mode.modeId];
738
+ if (value == null)
739
+ continue;
740
+ const resolved = typeof value === "object" && value !== null && "r" in value
741
+ ? (() => {
742
+ const c = value;
743
+ return `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${(c.a ?? 1).toFixed(2)})`;
744
+ })()
745
+ : String(value);
746
+ if (modeName.includes("dark")) {
747
+ figmaTokens.dark[name] = resolved;
748
+ }
749
+ else {
750
+ figmaTokens.light[name] = resolved;
751
+ }
752
+ }
753
+ }
754
+ // Compare
755
+ const allCodeKeys = new Set([...Object.keys(codeTokens.light), ...Object.keys(codeTokens.dark)]);
756
+ const allFigmaKeys = new Set([...Object.keys(figmaTokens.light), ...Object.keys(figmaTokens.dark)]);
757
+ const codeOnly = [...allCodeKeys].filter(k => !allFigmaKeys.has(k));
758
+ const figmaOnly = [...allFigmaKeys].filter(k => !allCodeKeys.has(k));
759
+ const drift = [];
760
+ let matched = 0;
761
+ for (const key of allCodeKeys) {
762
+ if (!allFigmaKeys.has(key))
763
+ continue;
764
+ matched++;
765
+ for (const mode of ["light", "dark"]) {
766
+ if (codeTokens[mode][key] && figmaTokens[mode][key] && codeTokens[mode][key] !== figmaTokens[mode][key]) {
767
+ drift.push({ token: key, mode, code: codeTokens[mode][key], figma: figmaTokens[mode][key] });
768
+ }
769
+ }
770
+ }
771
+ return {
772
+ status: drift.length === 0 && codeOnly.length === 0 && figmaOnly.length === 0 ? "in_sync" : "drift_detected",
773
+ codeTokenCount: allCodeKeys.size,
774
+ figmaTokenCount: allFigmaKeys.size,
775
+ matched,
776
+ codeOnly: codeOnly.slice(0, 30),
777
+ figmaOnly: figmaOnly.slice(0, 30),
778
+ drift: drift.slice(0, 30),
779
+ _hint: drift.length > 0
780
+ ? `${drift.length} tokens have drifted between Figma and code. Update src/index.css or Figma to reconcile.`
781
+ : "Design tokens are in sync.",
782
+ };
783
+ }
784
+ catch (err) {
785
+ return { error: true, message: `Figma API error: ${err.message}` };
786
+ }
787
+ },
788
+ },
789
+ // ── get_figma_design_context ──────────────────────────────────────────
790
+ {
791
+ name: "get_figma_design_context",
792
+ description: "Get design context for a specific component or screen from the codebase governance spec. Returns the relevant design tokens, typography rules, spacing rules, and component patterns that apply. Use this to understand what a component SHOULD look like according to the design system.",
793
+ inputSchema: {
794
+ type: "object",
795
+ properties: {
796
+ component: {
797
+ type: "string",
798
+ description: "Component or context name (e.g. 'button', 'card', 'page-shell', 'sidebar', 'stat-badge', 'empty-state')",
799
+ },
800
+ },
801
+ required: ["component"],
802
+ },
803
+ handler: async (args) => {
804
+ const component = (args.component ?? "").toLowerCase();
805
+ // Component-specific design context from the governance spec
806
+ const COMPONENT_CONTEXT = {
807
+ button: {
808
+ tokens: ["--primary", "--primary-foreground", "--destructive", "--ring"],
809
+ typography: [".type-body (14px/500)", "no uppercase unless metadata label"],
810
+ spacing: ["px-4 py-2 (md)", "px-3 py-1.5 (sm)", "px-2 py-1 (xs)"],
811
+ patterns: ["Use .btn-primary, .btn-secondary, .btn-ghost, .btn-outline", "focus-visible:ring-ring (semantic)", "No inline bg-indigo-600 text-white"],
812
+ notes: "Always use btn-* utility classes. Inline button styles are a governance violation.",
813
+ },
814
+ card: {
815
+ tokens: ["--card", "--card-foreground", "--border", "--radius"],
816
+ typography: [".type-section-title for card headers", ".type-body for card content"],
817
+ spacing: ["p-4 sm:p-6 standard card padding", "gap-3 between card sections"],
818
+ patterns: ["Use .nb-surface-card", "border border-border/50 rounded-lg", "Consistent border-radius across all cards on same screen"],
819
+ notes: "All cards must use nb-surface-card for consistent appearance.",
820
+ },
821
+ "page-shell": {
822
+ tokens: ["--background", "--foreground"],
823
+ typography: [".type-page-title for page headers", ".type-section-title for section headers"],
824
+ spacing: ["nb-page-shell > nb-page-inner > nb-page-frame", "space-y-6 between sections", "gap-8 section-gap"],
825
+ patterns: ["Every view MUST use nb-page-shell", "max-w-7xl mx-auto for content width"],
826
+ notes: "Views missing nb-page-shell are a high-severity governance violation.",
827
+ },
828
+ sidebar: {
829
+ tokens: ["--card", "--border", "--muted-foreground"],
830
+ typography: [".type-label for section headings (uppercase OK here)", ".type-body for nav items"],
831
+ spacing: ["p-3 standard sidebar padding", "gap-1 between nav items"],
832
+ patterns: ["Sidebar labels are the ONE place uppercase is allowed", "z-sidebar (20) for stacking"],
833
+ notes: "Sidebar section labels (MENU, MORE, FILES) may use uppercase. This is the exception.",
834
+ },
835
+ "empty-state": {
836
+ tokens: ["--muted-foreground", "--muted"],
837
+ typography: [".type-body for empty state message", ".type-caption for hint text"],
838
+ spacing: ["py-8 px-4 centered content", "gap-3 between icon and text"],
839
+ patterns: ["Import EmptyState component", "Icon (24px muted) + title + description + optional CTA", "Every data view MUST have an empty state"],
840
+ notes: "Views without empty state handling are a high-severity governance violation.",
841
+ },
842
+ "stat-badge": {
843
+ tokens: ["--foreground", "--muted-foreground", "--accent"],
844
+ typography: [".type-heading for the number", ".type-caption for the label"],
845
+ spacing: ["p-3 for stat cards", "gap-2 between stat items"],
846
+ patterns: ["Single accent color for primary metric", "text-content-muted for secondary", "Keep status dots colored (semantic), remove decorative color from numbers"],
847
+ notes: "Multi-colored stat numbers are a governance violation. Use monochrome.",
848
+ },
849
+ };
850
+ const ctx = COMPONENT_CONTEXT[component];
851
+ if (ctx) {
852
+ return {
853
+ component,
854
+ found: true,
855
+ ...ctx,
856
+ spec: DESIGN_SPEC,
857
+ };
858
+ }
859
+ // Fuzzy match
860
+ const candidates = Object.keys(COMPONENT_CONTEXT).filter(k => k.includes(component) || component.includes(k));
861
+ return {
862
+ component,
863
+ found: false,
864
+ message: `No specific design context for '${component}'.`,
865
+ didYouMean: candidates.length > 0 ? candidates : Object.keys(COMPONENT_CONTEXT),
866
+ spec: DESIGN_SPEC,
867
+ _hint: "The full design spec is included. Check spec.colorBudget, spec.typography, and spec.components for general rules.",
868
+ };
869
+ },
870
+ },
871
+ ];
872
+ //# sourceMappingURL=designGovernanceTools.js.map