We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/igorvieira/mcp-component-review'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join, relative } from 'path';
import { analyzeComponent } from './analyze.js';
import { compareComponentFiles } from './compare.js';
import { visualDiffComponents } from './visual-diff.js';
import { validateDesignImplementation } from './figma.js';
import type { ValidationReport } from '../figma/validator.js';
export interface BranchReviewInput {
repoPath: string;
baseBranch?: string;
targetBranch?: string;
figmaToken?: string;
figmaFileKey?: string;
figmaMapping?: Record<string, string>; // componentPath -> figmaNodeId
outputDir?: string;
}
export interface ComponentChange {
filePath: string;
relativePath: string;
status: 'added' | 'modified' | 'deleted';
oldContent?: string;
newContent?: string;
}
export interface BranchReviewResult {
summary: {
totalComponents: number;
added: number;
modified: number;
deleted: number;
validated: number;
validationScore?: number;
};
components: ComponentReviewResult[];
timestamp: string;
}
export interface ComponentReviewResult {
filePath: string;
status: 'added' | 'modified' | 'deleted';
analysis?: {
name: string;
propsCount: number;
childrenCount: number;
stylesCount: {
colors: number;
spacing: number;
typography: number;
};
};
structuralDiff?: {
textChanges: number;
childChanges: number;
propChanges: number;
styleChanges: number;
};
visualDiff?: {
diffPercentage: number;
diffPixels: number;
imagePaths?: {
old: string;
new: string;
diff: string;
combined: string;
};
};
figmaValidation?: {
score: number;
matches: number;
mismatches: number;
missing: number;
issues: string[];
};
}
/**
* Get list of changed React component files between branches
*/
export function getChangedComponents(
repoPath: string,
baseBranch: string = 'main',
targetBranch: string = 'HEAD'
): ComponentChange[] {
const changes: ComponentChange[] = [];
try {
// Get diff between branches
const diffOutput = execSync(
`git diff --name-status ${baseBranch}...${targetBranch}`,
{ cwd: repoPath, encoding: 'utf-8' }
);
const lines = diffOutput.trim().split('\n').filter(l => l);
for (const line of lines) {
const [status, filePath] = line.split('\t');
// Filter for React component files
if (!filePath?.match(/\.(tsx|jsx)$/) || filePath.includes('.test.') || filePath.includes('.spec.')) {
continue;
}
const fullPath = join(repoPath, filePath);
const change: ComponentChange = {
filePath: fullPath,
relativePath: filePath,
status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified'
};
// Get file contents
if (change.status !== 'added') {
try {
change.oldContent = execSync(
`git show ${baseBranch}:${filePath}`,
{ cwd: repoPath, encoding: 'utf-8' }
);
} catch {
// File might not exist in base branch
}
}
if (change.status !== 'deleted' && existsSync(fullPath)) {
change.newContent = readFileSync(fullPath, 'utf-8');
}
changes.push(change);
}
} catch (error) {
throw new Error(`Failed to get git diff: ${error}`);
}
return changes;
}
/**
* Review all changed components in a branch
*/
export async function reviewBranch(input: BranchReviewInput): Promise<BranchReviewResult> {
const {
repoPath,
baseBranch = 'main',
targetBranch = 'HEAD',
figmaToken,
figmaFileKey,
figmaMapping = {},
outputDir
} = input;
// Get changed components
const changes = getChangedComponents(repoPath, baseBranch, targetBranch);
const results: ComponentReviewResult[] = [];
let totalValidationScore = 0;
let validatedCount = 0;
for (const change of changes) {
const result: ComponentReviewResult = {
filePath: change.relativePath,
status: change.status
};
// Analyze current component (if not deleted)
if (change.status !== 'deleted' && change.newContent) {
try {
const analysis = analyzeComponent({ file: change.filePath });
result.analysis = {
name: analysis.name,
propsCount: analysis.props.length,
childrenCount: countChildren(analysis.children),
stylesCount: {
colors: analysis.styles.colors.length,
spacing: analysis.styles.spacing.length,
typography: analysis.styles.typography.length
}
};
} catch (e) {
// Analysis failed, continue
}
}
// Compare with old version (if modified)
if (change.status === 'modified' && change.oldContent && change.newContent) {
try {
// Create temp files for comparison
const { writeFileSync, mkdtempSync } = await import('fs');
const { tmpdir } = await import('os');
const tempDir = mkdtempSync(join(tmpdir(), 'branch-review-'));
const oldFile = join(tempDir, 'old.tsx');
const newFile = join(tempDir, 'new.tsx');
writeFileSync(oldFile, change.oldContent);
writeFileSync(newFile, change.newContent);
const comparison = compareComponentFiles({ oldFile, newFile });
result.structuralDiff = {
textChanges: comparison.changes.text.length,
childChanges: comparison.changes.children.length,
propChanges: comparison.changes.props.length,
styleChanges:
comparison.changes.styles.colors.length +
comparison.changes.styles.spacing.length +
comparison.changes.styles.typography.length
};
// Visual diff
if (outputDir) {
try {
const visualResult = await visualDiffComponents({
oldFile,
newFile,
outputDir: join(outputDir, change.relativePath.replace(/[\/\\]/g, '_'))
});
result.visualDiff = {
diffPercentage: visualResult.diffPercentage,
diffPixels: visualResult.diffPixels,
imagePaths: visualResult.paths
};
} catch {
// Visual diff failed, continue
}
}
// Cleanup temp files
const { rmSync } = await import('fs');
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Comparison failed, continue
}
}
// Figma validation (if token and mapping provided)
if (figmaToken && figmaFileKey && figmaMapping[change.relativePath]) {
try {
const validation = await validateDesignImplementation({
fileKey: figmaFileKey,
nodeId: figmaMapping[change.relativePath],
figmaToken,
componentFile: change.filePath
});
if (validation.success && validation.report) {
result.figmaValidation = {
score: validation.report.summary.score,
matches: validation.report.summary.matches,
mismatches: validation.report.summary.mismatches,
missing: validation.report.summary.missing,
issues: validation.report.validations
.filter(v => v.status !== 'match')
.map(v => `${v.property}: ${v.status}`)
};
totalValidationScore += validation.report.summary.score;
validatedCount++;
}
} catch {
// Figma validation failed, continue
}
}
results.push(result);
}
return {
summary: {
totalComponents: changes.length,
added: changes.filter(c => c.status === 'added').length,
modified: changes.filter(c => c.status === 'modified').length,
deleted: changes.filter(c => c.status === 'deleted').length,
validated: validatedCount,
validationScore: validatedCount > 0 ? Math.round(totalValidationScore / validatedCount) : undefined
},
components: results,
timestamp: new Date().toISOString()
};
}
function countChildren(children: any[]): number {
let count = children.length;
for (const child of children) {
if (child.children) {
count += countChildren(child.children);
}
}
return count;
}
/**
* Format branch review result as markdown
*/
export function formatBranchReviewResult(result: BranchReviewResult): string {
const lines: string[] = [];
lines.push('# Branch Component Review Report');
lines.push('');
lines.push(`*Generated: ${result.timestamp}*`);
lines.push('');
// Summary
lines.push('## Summary');
lines.push('');
lines.push(`| Metric | Value |`);
lines.push(`|--------|-------|`);
lines.push(`| Total Components | ${result.summary.totalComponents} |`);
lines.push(`| Added | ${result.summary.added} |`);
lines.push(`| Modified | ${result.summary.modified} |`);
lines.push(`| Deleted | ${result.summary.deleted} |`);
if (result.summary.validated > 0) {
lines.push(`| Validated against Figma | ${result.summary.validated} |`);
lines.push(`| Average Validation Score | ${result.summary.validationScore}% |`);
}
lines.push('');
// Component details
lines.push('## Component Details');
lines.push('');
for (const comp of result.components) {
const statusIcon = comp.status === 'added' ? '➕' : comp.status === 'deleted' ? '➖' : '✏️';
lines.push(`### ${statusIcon} ${comp.filePath}`);
lines.push('');
if (comp.analysis) {
lines.push(`**Component:** ${comp.analysis.name}`);
lines.push(`- Props: ${comp.analysis.propsCount}`);
lines.push(`- Children: ${comp.analysis.childrenCount}`);
lines.push(`- Styles: ${comp.analysis.stylesCount.colors} colors, ${comp.analysis.stylesCount.spacing} spacing, ${comp.analysis.stylesCount.typography} typography`);
lines.push('');
}
if (comp.structuralDiff) {
lines.push('**Structural Changes:**');
lines.push(`- Text changes: ${comp.structuralDiff.textChanges}`);
lines.push(`- Child component changes: ${comp.structuralDiff.childChanges}`);
lines.push(`- Prop changes: ${comp.structuralDiff.propChanges}`);
lines.push(`- Style changes: ${comp.structuralDiff.styleChanges}`);
lines.push('');
}
if (comp.visualDiff) {
lines.push('**Visual Diff:**');
lines.push(`- Difference: ${comp.visualDiff.diffPercentage}%`);
lines.push(`- Changed pixels: ${comp.visualDiff.diffPixels.toLocaleString()}`);
if (comp.visualDiff.imagePaths) {
lines.push(`- Combined view: ${comp.visualDiff.imagePaths.combined}`);
}
lines.push('');
}
if (comp.figmaValidation) {
const scoreIcon = comp.figmaValidation.score >= 90 ? '✅' : comp.figmaValidation.score >= 70 ? '⚠️' : '❌';
lines.push(`**Figma Validation:** ${scoreIcon} ${comp.figmaValidation.score}%`);
lines.push(`- Matches: ${comp.figmaValidation.matches}`);
lines.push(`- Mismatches: ${comp.figmaValidation.mismatches}`);
lines.push(`- Missing: ${comp.figmaValidation.missing}`);
if (comp.figmaValidation.issues.length > 0) {
lines.push('- Issues:');
for (const issue of comp.figmaValidation.issues) {
lines.push(` - ${issue}`);
}
}
lines.push('');
}
lines.push('---');
lines.push('');
}
return lines.join('\n');
}