feat(refactoring): introduce comprehensive guidelines and tools for component refactoring in Dify (#30162)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Coding On Star
2025-12-25 18:19:28 +08:00
committed by GitHub
co-authored by CodingOnStar autofix-ci[bot] yyh
parent c3bb95d71d
commit f2555b0bb1
10 changed files with 2687 additions and 470 deletions
+2 -1
View File
@@ -38,7 +38,8 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"analyze-component": "node testing/analyze-component.js",
"analyze-component": "node ./scripts/analyze-component.js",
"refactor-component": "node ./scripts/refactor-component.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"preinstall": "npx only-allow pnpm",
@@ -3,376 +3,13 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import tsParser from '@typescript-eslint/parser'
import { Linter } from 'eslint'
import sonarPlugin from 'eslint-plugin-sonarjs'
// ============================================================================
// Simple Analyzer
// ============================================================================
class ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(filePath, path.extname(filePath))
const lineCount = code.split('\n').length
// Calculate complexity metrics
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
const complexity = this.normalizeComplexity(rawComplexity)
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
// Count usage references (may take a few seconds)
const usageCount = this.countUsageReferences(filePath, resolvedPath)
// Calculate test priority
const priority = this.calculateTestPriority(complexity, usageCount)
return {
name: fileName.charAt(0).toUpperCase() + fileName.slice(1),
path: filePath,
type: this.detectType(filePath, code),
hasProps: code.includes('Props') || code.includes('interface'),
hasState: code.includes('useState') || code.includes('useReducer'),
hasEffects: code.includes('useEffect'),
hasCallbacks: code.includes('useCallback'),
hasMemo: code.includes('useMemo'),
hasEvents: /on[A-Z]\w+/.test(code),
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
hasForwardRef: code.includes('forwardRef'),
hasComponentMemo: /React\.memo|memo\(/.test(code),
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
hasPortal: code.includes('createPortal'),
hasImperativeHandle: code.includes('useImperativeHandle'),
hasSWR: code.includes('useSWR'),
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
hasAhooks: code.includes('from \'ahooks\''),
complexity,
maxComplexity,
rawComplexity,
rawMaxComplexity,
lineCount,
usageCount,
priority,
}
}
detectType(filePath, code) {
const normalizedPath = filePath.replace(/\\/g, '/')
if (normalizedPath.includes('/hooks/'))
return 'hook'
if (normalizedPath.includes('/utils/'))
return 'util'
if (/\/page\.(t|j)sx?$/.test(normalizedPath))
return 'page'
if (/\/layout\.(t|j)sx?$/.test(normalizedPath))
return 'layout'
if (/\/providers?\//.test(normalizedPath))
return 'provider'
// Dify-specific types
if (normalizedPath.includes('/components/base/'))
return 'base-component'
if (normalizedPath.includes('/context/'))
return 'context'
if (normalizedPath.includes('/store/'))
return 'store'
if (normalizedPath.includes('/service/'))
return 'service'
if (/use[A-Z]\w+/.test(code))
return 'component'
return 'component'
}
/**
* Calculate Cognitive Complexity using SonarJS ESLint plugin
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
*
* Returns raw (unnormalized) complexity values:
* - total: sum of all functions' complexity in the file
* - max: highest single function complexity in the file
*
* Raw Score Thresholds (per function):
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
*
* @returns {{ total: number, max: number }} raw total and max complexity
*/
calculateCognitiveComplexity(code) {
const linter = new Linter()
const baseConfig = {
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
plugins: { sonarjs: sonarPlugin },
}
try {
// Get total complexity using 'metric' option (more stable)
const totalConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
}
const totalMessages = linter.verify(code, totalConfig)
const totalMsg = totalMessages.find(
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
&& msg.messageId === 'fileComplexity',
)
const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0
// Get max function complexity by analyzing each function
const maxConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
}
const maxMessages = linter.verify(code, maxConfig)
let max = 0
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
maxMessages.forEach((msg) => {
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
const match = msg.message.match(complexityPattern)
if (match && match[1])
max = Math.max(max, Number.parseInt(match[1], 10))
}
})
return { total, max }
}
catch {
return { total: 0, max: 0 }
}
}
/**
* Normalize cognitive complexity to 0-100 scale
*
* Mapping (aligned with SonarJS thresholds):
* Raw 0-15 (Simple) -> Normalized 0-25
* Raw 16-30 (Medium) -> Normalized 25-50
* Raw 31-50 (Complex) -> Normalized 50-75
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
*/
normalizeComplexity(rawComplexity) {
if (rawComplexity <= 15) {
// Linear: 0-15 -> 0-25
return Math.round((rawComplexity / 15) * 25)
}
else if (rawComplexity <= 30) {
// Linear: 16-30 -> 25-50
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
}
else if (rawComplexity <= 50) {
// Linear: 31-50 -> 50-75
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
}
else {
// Asymptotic: 51+ -> 75-100
// Formula ensures score approaches but never exceeds 100
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
}
}
/**
* Count how many times a component is referenced in the codebase
* Scans TypeScript sources for import statements referencing the component
*/
countUsageReferences(filePath, absolutePath) {
try {
const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath))
let searchName = fileName
if (fileName === 'index') {
const parentDir = path.dirname(resolvedComponentPath)
searchName = path.basename(parentDir)
}
if (!searchName)
return 0
const searchRoots = this.collectSearchRoots(resolvedComponentPath)
if (searchRoots.length === 0)
return 0
const escapedName = ComponentAnalyzer.escapeRegExp(searchName)
const patterns = [
new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
]
const visited = new Set()
let usageCount = 0
const stack = [...searchRoots]
while (stack.length > 0) {
const currentDir = stack.pop()
if (!currentDir || visited.has(currentDir))
continue
visited.add(currentDir)
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
entries.forEach((entry) => {
const entryPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (this.shouldSkipDir(entry.name))
return
stack.push(entryPath)
return
}
if (!this.shouldInspectFile(entry.name))
return
const normalizedEntryPath = path.resolve(entryPath)
if (normalizedEntryPath === path.resolve(resolvedComponentPath))
return
const source = fs.readFileSync(entryPath, 'utf-8')
if (!source.includes(searchName))
return
if (patterns.some((pattern) => {
pattern.lastIndex = 0
return pattern.test(source)
})) {
usageCount += 1
}
})
}
return usageCount
}
catch {
// If command fails, return 0
return 0
}
}
collectSearchRoots(resolvedComponentPath) {
const roots = new Set()
let currentDir = path.dirname(resolvedComponentPath)
const workspaceRoot = process.cwd()
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (path.basename(currentDir) === 'app') {
roots.add(currentDir)
break
}
if (currentDir === workspaceRoot)
break
currentDir = path.dirname(currentDir)
}
const fallbackRoots = [
path.join(workspaceRoot, 'app'),
path.join(workspaceRoot, 'web', 'app'),
path.join(workspaceRoot, 'src'),
]
fallbackRoots.forEach((root) => {
if (fs.existsSync(root) && fs.statSync(root).isDirectory())
roots.add(root)
})
return Array.from(roots)
}
shouldSkipDir(dirName) {
const normalized = dirName.toLowerCase()
return [
'node_modules',
'.git',
'.next',
'dist',
'out',
'coverage',
'build',
'__tests__',
'__mocks__',
].includes(normalized)
}
shouldInspectFile(fileName) {
const normalized = fileName.toLowerCase()
if (!(/\.(ts|tsx)$/i.test(fileName)))
return false
if (normalized.endsWith('.d.ts'))
return false
if (/\.(spec|test)\.(ts|tsx)$/.test(normalized))
return false
if (normalized.endsWith('.stories.tsx'))
return false
return true
}
static escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Calculate test priority based on cognitive complexity and usage
*
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
* - Complexity Score: 0-100 (normalized from SonarJS)
* - Usage Score: 0-100 (based on reference count)
*
* Priority Levels (0-100):
* - 0-25: 🟢 LOW
* - 26-50: 🟡 MEDIUM
* - 51-75: 🟠 HIGH
* - 76-100: 🔴 CRITICAL
*/
calculateTestPriority(complexity, usageCount) {
const complexityScore = complexity
// Normalize usage score to 0-100
let usageScore
if (usageCount === 0)
usageScore = 0
else if (usageCount <= 5)
usageScore = 20
else if (usageCount <= 20)
usageScore = 40
else if (usageCount <= 50)
usageScore = 70
else
usageScore = 100
// Weighted average: complexity (70%) + usage (30%)
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
return {
score: totalScore,
level: this.getPriorityLevel(totalScore),
usageScore,
complexityScore,
}
}
/**
* Get priority level based on score (0-100 scale)
*/
getPriorityLevel(score) {
if (score > 75)
return '🔴 CRITICAL'
if (score > 50)
return '🟠 HIGH'
if (score > 25)
return '🟡 MEDIUM'
return '🟢 LOW'
}
}
import {
ComponentAnalyzer,
extractCopyContent,
getComplexityLevel,
listAnalyzableFiles,
resolveDirectoryEntry,
} from './component-analyzer.js'
// ============================================================================
// Prompt Builder for AI Assistants
@@ -394,8 +31,8 @@ class TestPromptBuilder {
📊 Component Analysis:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Type: ${analysis.type}
Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)}
Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)}
Lines: ${analysis.lineCount}
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
Test Priority: ${analysis.priority.score} ${analysis.priority.level}
@@ -444,17 +81,6 @@ Create the test file at: ${testPath}
`
}
getComplexityLevel(score) {
// Normalized complexity thresholds (0-100 scale)
if (score <= 25)
return '🟢 Simple'
if (score <= 50)
return '🟡 Medium'
if (score <= 75)
return '🟠 Complex'
return '🔴 Very Complex'
}
buildFocusPoints(analysis) {
const points = []
@@ -730,94 +356,10 @@ Output format:
}
}
function extractCopyContent(prompt) {
const marker = '📋 PROMPT FOR AI ASSISTANT'
const markerIndex = prompt.indexOf(marker)
if (markerIndex === -1)
return ''
const section = prompt.slice(markerIndex)
const lines = section.split('\n')
const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━'))
if (firstDivider === -1)
return ''
const startIdx = firstDivider + 1
let endIdx = lines.length
for (let i = startIdx; i < lines.length; i++) {
if (lines[i].includes('━━━━━━━━')) {
endIdx = i
break
}
}
if (startIdx >= endIdx)
return ''
return lines.slice(startIdx, endIdx).join('\n').trim()
}
// ============================================================================
// Main Function
// ============================================================================
/**
* Resolve directory to entry file
* Priority: index files > common entry files (node.tsx, panel.tsx, etc.)
*/
function resolveDirectoryEntry(absolutePath, componentPath) {
// Entry files in priority order: index files first, then common entry files
const entryFiles = [
'index.tsx',
'index.ts', // Priority 1: index files
'node.tsx',
'panel.tsx',
'component.tsx',
'main.tsx',
'container.tsx', // Priority 2: common entry files
]
for (const entryFile of entryFiles) {
const entryPath = path.join(absolutePath, entryFile)
if (fs.existsSync(entryPath)) {
return {
absolutePath: entryPath,
componentPath: path.join(componentPath, entryFile),
}
}
}
return null
}
/**
* List analyzable files in directory (for user guidance)
*/
function listAnalyzableFiles(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
return entries
.filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts'))
.map(entry => entry.name)
.sort((a, b) => {
// Prioritize common entry files
const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx']
const aIdx = priority.indexOf(a)
const bIdx = priority.indexOf(b)
if (aIdx !== -1 && bIdx !== -1)
return aIdx - bIdx
if (aIdx !== -1)
return -1
if (bIdx !== -1)
return 1
return a.localeCompare(b)
})
}
catch {
return []
}
}
function showHelp() {
console.log(`
📋 Component Analyzer - Generate test prompts for AI assistants
+484
View File
@@ -0,0 +1,484 @@
/**
* Component Analyzer - Shared module for analyzing React component complexity
*
* This module is used by:
* - analyze-component.js (for test generation)
* - refactor-component.js (for refactoring suggestions)
*/
import fs from 'node:fs'
import path from 'node:path'
import tsParser from '@typescript-eslint/parser'
import { Linter } from 'eslint'
import sonarPlugin from 'eslint-plugin-sonarjs'
// ============================================================================
// Component Analyzer
// ============================================================================
export class ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(filePath, path.extname(filePath))
const lineCount = code.split('\n').length
// Calculate complexity metrics
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
const complexity = this.normalizeComplexity(rawComplexity)
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
// Count usage references (may take a few seconds)
const usageCount = this.countUsageReferences(filePath, resolvedPath)
// Calculate test priority
const priority = this.calculateTestPriority(complexity, usageCount)
return {
name: fileName.charAt(0).toUpperCase() + fileName.slice(1),
path: filePath,
type: this.detectType(filePath, code),
hasProps: code.includes('Props') || code.includes('interface'),
hasState: code.includes('useState') || code.includes('useReducer'),
hasEffects: code.includes('useEffect'),
hasCallbacks: code.includes('useCallback'),
hasMemo: code.includes('useMemo'),
hasEvents: /on[A-Z]\w+/.test(code),
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
hasForwardRef: code.includes('forwardRef'),
hasComponentMemo: /React\.memo|memo\(/.test(code),
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
hasPortal: code.includes('createPortal'),
hasImperativeHandle: code.includes('useImperativeHandle'),
hasSWR: code.includes('useSWR'),
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
hasAhooks: code.includes('from \'ahooks\''),
complexity,
maxComplexity,
rawComplexity,
rawMaxComplexity,
lineCount,
usageCount,
priority,
}
}
detectType(filePath, code) {
const normalizedPath = filePath.replace(/\\/g, '/')
if (normalizedPath.includes('/hooks/'))
return 'hook'
if (normalizedPath.includes('/utils/'))
return 'util'
if (/\/page\.(t|j)sx?$/.test(normalizedPath))
return 'page'
if (/\/layout\.(t|j)sx?$/.test(normalizedPath))
return 'layout'
if (/\/providers?\//.test(normalizedPath))
return 'provider'
// Dify-specific types
if (normalizedPath.includes('/components/base/'))
return 'base-component'
if (normalizedPath.includes('/context/'))
return 'context'
if (normalizedPath.includes('/store/'))
return 'store'
if (normalizedPath.includes('/service/'))
return 'service'
if (/use[A-Z]\w+/.test(code))
return 'component'
return 'component'
}
/**
* Calculate Cognitive Complexity using SonarJS ESLint plugin
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
*
* Returns raw (unnormalized) complexity values:
* - total: sum of all functions' complexity in the file
* - max: highest single function complexity in the file
*
* Raw Score Thresholds (per function):
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
*
* @returns {{ total: number, max: number }} raw total and max complexity
*/
calculateCognitiveComplexity(code) {
const linter = new Linter()
const baseConfig = {
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
plugins: { sonarjs: sonarPlugin },
}
try {
// Get total complexity using 'metric' option (more stable)
const totalConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
}
const totalMessages = linter.verify(code, totalConfig)
const totalMsg = totalMessages.find(
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
&& msg.messageId === 'fileComplexity',
)
const total = totalMsg ? Number.parseInt(totalMsg.message, 10) : 0
// Get max function complexity by analyzing each function
const maxConfig = {
...baseConfig,
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
}
const maxMessages = linter.verify(code, maxConfig)
let max = 0
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
maxMessages.forEach((msg) => {
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
const match = msg.message.match(complexityPattern)
if (match && match[1])
max = Math.max(max, Number.parseInt(match[1], 10))
}
})
return { total, max }
}
catch {
return { total: 0, max: 0 }
}
}
/**
* Normalize cognitive complexity to 0-100 scale
*
* Mapping (aligned with SonarJS thresholds):
* Raw 0-15 (Simple) -> Normalized 0-25
* Raw 16-30 (Medium) -> Normalized 25-50
* Raw 31-50 (Complex) -> Normalized 50-75
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
*/
normalizeComplexity(rawComplexity) {
if (rawComplexity <= 15) {
// Linear: 0-15 -> 0-25
return Math.round((rawComplexity / 15) * 25)
}
else if (rawComplexity <= 30) {
// Linear: 16-30 -> 25-50
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
}
else if (rawComplexity <= 50) {
// Linear: 31-50 -> 50-75
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
}
else {
// Asymptotic: 51+ -> 75-100
// Formula ensures score approaches but never exceeds 100
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
}
}
/**
* Count how many times a component is referenced in the codebase
* Scans TypeScript sources for import statements referencing the component
*/
countUsageReferences(filePath, absolutePath) {
try {
const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath))
let searchName = fileName
if (fileName === 'index') {
const parentDir = path.dirname(resolvedComponentPath)
searchName = path.basename(parentDir)
}
if (!searchName)
return 0
const searchRoots = this.collectSearchRoots(resolvedComponentPath)
if (searchRoots.length === 0)
return 0
const escapedName = ComponentAnalyzer.escapeRegExp(searchName)
const patterns = [
new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
]
const visited = new Set()
let usageCount = 0
const stack = [...searchRoots]
while (stack.length > 0) {
const currentDir = stack.pop()
if (!currentDir || visited.has(currentDir))
continue
visited.add(currentDir)
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
entries.forEach((entry) => {
const entryPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
if (this.shouldSkipDir(entry.name))
return
stack.push(entryPath)
return
}
if (!this.shouldInspectFile(entry.name))
return
const normalizedEntryPath = path.resolve(entryPath)
if (normalizedEntryPath === path.resolve(resolvedComponentPath))
return
const source = fs.readFileSync(entryPath, 'utf-8')
if (!source.includes(searchName))
return
if (patterns.some((pattern) => {
pattern.lastIndex = 0
return pattern.test(source)
})) {
usageCount += 1
}
})
}
return usageCount
}
catch {
// If command fails, return 0
return 0
}
}
collectSearchRoots(resolvedComponentPath) {
const roots = new Set()
let currentDir = path.dirname(resolvedComponentPath)
const workspaceRoot = process.cwd()
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (path.basename(currentDir) === 'app') {
roots.add(currentDir)
break
}
if (currentDir === workspaceRoot)
break
currentDir = path.dirname(currentDir)
}
const fallbackRoots = [
path.join(workspaceRoot, 'app'),
path.join(workspaceRoot, 'web', 'app'),
path.join(workspaceRoot, 'src'),
]
fallbackRoots.forEach((root) => {
if (fs.existsSync(root) && fs.statSync(root).isDirectory())
roots.add(root)
})
return Array.from(roots)
}
shouldSkipDir(dirName) {
const normalized = dirName.toLowerCase()
return [
'node_modules',
'.git',
'.next',
'dist',
'out',
'coverage',
'build',
'__tests__',
'__mocks__',
].includes(normalized)
}
shouldInspectFile(fileName) {
const normalized = fileName.toLowerCase()
if (!(/\.(ts|tsx)$/i.test(fileName)))
return false
if (normalized.endsWith('.d.ts'))
return false
if (/\.(spec|test)\.(ts|tsx)$/.test(normalized))
return false
if (normalized.endsWith('.stories.tsx'))
return false
return true
}
static escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Calculate test priority based on cognitive complexity and usage
*
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
* - Complexity Score: 0-100 (normalized from SonarJS)
* - Usage Score: 0-100 (based on reference count)
*
* Priority Levels (0-100):
* - 0-25: 🟢 LOW
* - 26-50: 🟡 MEDIUM
* - 51-75: 🟠 HIGH
* - 76-100: 🔴 CRITICAL
*/
calculateTestPriority(complexity, usageCount) {
const complexityScore = complexity
// Normalize usage score to 0-100
let usageScore
if (usageCount === 0)
usageScore = 0
else if (usageCount <= 5)
usageScore = 20
else if (usageCount <= 20)
usageScore = 40
else if (usageCount <= 50)
usageScore = 70
else
usageScore = 100
// Weighted average: complexity (70%) + usage (30%)
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
return {
score: totalScore,
level: this.getPriorityLevel(totalScore),
usageScore,
complexityScore,
}
}
/**
* Get priority level based on score (0-100 scale)
*/
getPriorityLevel(score) {
if (score > 75)
return '🔴 CRITICAL'
if (score > 50)
return '🟠 HIGH'
if (score > 25)
return '🟡 MEDIUM'
return '🟢 LOW'
}
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Resolve directory to entry file
* Priority: index files > common entry files (node.tsx, panel.tsx, etc.)
*/
export function resolveDirectoryEntry(absolutePath, componentPath) {
// Entry files in priority order: index files first, then common entry files
const entryFiles = [
'index.tsx',
'index.ts', // Priority 1: index files
'node.tsx',
'panel.tsx',
'component.tsx',
'main.tsx',
'container.tsx', // Priority 2: common entry files
]
for (const entryFile of entryFiles) {
const entryPath = path.join(absolutePath, entryFile)
if (fs.existsSync(entryPath)) {
return {
absolutePath: entryPath,
componentPath: path.join(componentPath, entryFile),
}
}
}
return null
}
/**
* List analyzable files in directory (for user guidance)
*/
export function listAnalyzableFiles(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
return entries
.filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts'))
.map(entry => entry.name)
.sort((a, b) => {
// Prioritize common entry files
const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx']
const aIdx = priority.indexOf(a)
const bIdx = priority.indexOf(b)
if (aIdx !== -1 && bIdx !== -1)
return aIdx - bIdx
if (aIdx !== -1)
return -1
if (bIdx !== -1)
return 1
return a.localeCompare(b)
})
}
catch {
return []
}
}
/**
* Extract copy content from prompt (for clipboard)
*/
export function extractCopyContent(prompt) {
const marker = '📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):'
const markerIndex = prompt.indexOf(marker)
if (markerIndex === -1)
return ''
const section = prompt.slice(markerIndex)
const lines = section.split('\n')
const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━'))
if (firstDivider === -1)
return ''
const startIdx = firstDivider + 1
let endIdx = lines.length
for (let i = startIdx; i < lines.length; i++) {
if (lines[i].includes('━━━━━━━━')) {
endIdx = i
break
}
}
if (startIdx >= endIdx)
return ''
return lines.slice(startIdx, endIdx).join('\n').trim()
}
/**
* Get complexity level label
*/
export function getComplexityLevel(score) {
if (score <= 25)
return '🟢 Simple'
if (score <= 50)
return '🟡 Medium'
if (score <= 75)
return '🟠 Complex'
return '🔴 Very Complex'
}
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import {
ComponentAnalyzer,
extractCopyContent,
getComplexityLevel,
listAnalyzableFiles,
resolveDirectoryEntry,
} from './component-analyzer.js'
// ============================================================================
// Extended Analyzer for Refactoring
// ============================================================================
class RefactorAnalyzer extends ComponentAnalyzer {
analyze(code, filePath, absolutePath) {
// Get base analysis from parent class
const baseAnalysis = super.analyze(code, filePath, absolutePath)
// Add refactoring-specific metrics
// Note: These counts use regex matching which may include import statements.
// For most components this results in +1 over actual usage, which is acceptable
// for heuristic analysis. For precise AST-based counting, consider using
// @typescript-eslint/parser to traverse the AST.
const stateCount = (code.match(/useState\s*[(<]/g) || []).length
const effectCount = (code.match(/useEffect\s*\(/g) || []).length
const callbackCount = (code.match(/useCallback\s*\(/g) || []).length
const memoCount = (code.match(/useMemo\s*\(/g) || []).length
const conditionalBlocks = this.countConditionalBlocks(code)
const nestedTernaries = this.countNestedTernaries(code)
const hasContext = code.includes('useContext') || code.includes('createContext')
const hasReducer = code.includes('useReducer')
const hasModals = this.countModals(code)
return {
...baseAnalysis,
stateCount,
effectCount,
callbackCount,
memoCount,
conditionalBlocks,
nestedTernaries,
hasContext,
hasReducer,
hasModals,
}
}
countModals(code) {
const modalPatterns = [
/Modal/g,
/Dialog/g,
/Drawer/g,
/Confirm/g,
/showModal|setShowModal|isShown|isShowing/g,
]
let count = 0
modalPatterns.forEach((pattern) => {
const matches = code.match(pattern)
if (matches)
count += matches.length
})
return Math.floor(count / 3) // Rough estimate of actual modals
}
countConditionalBlocks(code) {
const ifBlocks = (code.match(/\bif\s*\(/g) || []).length
const ternaries = (code.match(/\?.*:/g) || []).length
const switchCases = (code.match(/\bswitch\s*\(/g) || []).length
return ifBlocks + ternaries + switchCases
}
countNestedTernaries(code) {
const nestedInTrueBranch = (code.match(/\?[^:?]*\?[^:]*:/g) || []).length
const nestedInFalseBranch = (code.match(/\?[^:?]*:[^?]*\?[^:]*:/g) || []).length
return nestedInTrueBranch + nestedInFalseBranch
}
}
// ============================================================================
// Refactor Prompt Builder
// ============================================================================
class RefactorPromptBuilder {
build(analysis) {
const refactorActions = this.identifyRefactorActions(analysis)
return `
╔════════════════════════════════════════════════════════════════════════════╗
║ 🔧 REFACTOR DIFY COMPONENT ║
╚════════════════════════════════════════════════════════════════════════════╝
📍 Component: ${analysis.name}
📂 Path: ${analysis.path}
📊 Complexity Analysis:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total Complexity: ${analysis.complexity}/100 ${getComplexityLevel(analysis.complexity)}
Max Func Complexity: ${analysis.maxComplexity}/100 ${getComplexityLevel(analysis.maxComplexity)}
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '⚠️ TOO LARGE' : ''}
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
📈 Code Metrics:
useState calls: ${analysis.stateCount}
useEffect calls: ${analysis.effectCount}
useCallback calls: ${analysis.callbackCount}
useMemo calls: ${analysis.memoCount}
Conditional blocks: ${analysis.conditionalBlocks}
Nested ternaries: ${analysis.nestedTernaries}
Modal components: ${analysis.hasModals}
🔍 Features Detected:
${analysis.hasState ? '✓' : '✗'} Local state (useState/useReducer)
${analysis.hasEffects ? '✓' : '✗'} Side effects (useEffect)
${analysis.hasCallbacks ? '✓' : '✗'} Callbacks (useCallback)
${analysis.hasMemo ? '✓' : '✗'} Memoization (useMemo)
${analysis.hasContext ? '✓' : '✗'} Context (useContext/createContext)
${analysis.hasEvents ? '✓' : '✗'} Event handlers
${analysis.hasRouter ? '✓' : '✗'} Next.js routing
${analysis.hasAPI ? '✓' : '✗'} API calls
${analysis.hasReactQuery ? '✓' : '✗'} React Query
${analysis.hasSWR ? '✓' : '✗'} SWR (should migrate to React Query)
${analysis.hasAhooks ? '✓' : '✗'} ahooks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎯 RECOMMENDED REFACTORING ACTIONS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
${refactorActions.map((action, i) => `${i + 1}. ${action}`).join('\n')}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Please refactor the component at @${analysis.path}
Component metrics:
- Complexity: ${analysis.complexity}/100 (target: < 50)
- Lines: ${analysis.lineCount} (target: < 300)
- useState: ${analysis.stateCount}, useEffect: ${analysis.effectCount}
Refactoring tasks:
${refactorActions.map(action => `- ${action}`).join('\n')}
Requirements:
${this.buildRequirements(analysis)}
Follow Dify project conventions:
- Place extracted hooks in \`hooks/\` subdirectory or as \`use-<feature>.ts\`
- Use React Query (\`@tanstack/react-query\`) for data fetching, not SWR
- Follow existing patterns in \`web/service/use-*.ts\` for API hooks
- Keep each new file under 300 lines
- Maintain TypeScript strict typing
After refactoring, verify:
- \`pnpm lint:fix\` passes
- \`pnpm type-check:tsgo\` passes
- Re-run \`pnpm refactor-component ${analysis.path}\` to confirm complexity < 50
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`
}
identifyRefactorActions(analysis) {
const actions = []
// Priority 1: Extract hooks for complex state management
if (analysis.stateCount >= 3 || (analysis.stateCount >= 2 && analysis.effectCount >= 2)) {
actions.push(`🪝 EXTRACT CUSTOM HOOK: ${analysis.stateCount} useState + ${analysis.effectCount} useEffect detected. Extract related state and effects into a custom hook (e.g., \`use${analysis.name}State.ts\`)`)
}
// Priority 2: Extract API/data logic
if (analysis.hasAPI && (analysis.hasEffects || analysis.hasSWR)) {
if (analysis.hasSWR) {
actions.push('🔄 MIGRATE SWR TO REACT QUERY: Replace useSWR with useQuery from @tanstack/react-query')
}
actions.push('🌐 EXTRACT DATA HOOK: Move API calls and data fetching logic into a dedicated hook using React Query')
}
// Priority 3: Split large components
if (analysis.lineCount > 300) {
actions.push(`📦 SPLIT COMPONENT: ${analysis.lineCount} lines exceeds limit. Extract UI sections into sub-components`)
}
// Priority 4: Extract modal management
if (analysis.hasModals >= 2) {
actions.push(`🔲 EXTRACT MODAL MANAGEMENT: ${analysis.hasModals} modal-related patterns detected. Create a useModalState hook or separate modal components`)
}
// Priority 5: Simplify conditionals
if (analysis.conditionalBlocks > 10 || analysis.nestedTernaries >= 2) {
actions.push('🔀 SIMPLIFY CONDITIONALS: Use lookup tables, early returns, or extract complex conditions into named functions')
}
// Priority 6: Extract callbacks
if (analysis.callbackCount >= 4) {
actions.push(`⚡ CONSOLIDATE CALLBACKS: ${analysis.callbackCount} useCallback calls. Consider extracting related callbacks into a custom hook`)
}
// Priority 7: Context provider extraction
if (analysis.hasContext && analysis.complexity > 50) {
actions.push('🎯 EXTRACT CONTEXT LOGIC: Move context provider logic into separate files or split into domain-specific contexts')
}
// Priority 8: Memoization review
if (analysis.memoCount >= 3 && analysis.complexity > 50) {
actions.push(`📝 REVIEW MEMOIZATION: ${analysis.memoCount} useMemo calls. Extract complex computations into utility functions or hooks`)
}
// If no specific issues, provide general guidance
if (actions.length === 0) {
if (analysis.complexity > 50) {
actions.push('🔍 ANALYZE FUNCTIONS: Review individual functions for complexity and extract helper functions')
}
else {
actions.push('✅ Component complexity is acceptable. Consider minor improvements for maintainability')
}
}
return actions
}
buildRequirements(analysis) {
const requirements = []
if (analysis.stateCount >= 3) {
requirements.push('- Group related useState calls into a single custom hook')
requirements.push('- Move associated useEffect calls with the state they depend on')
}
if (analysis.hasAPI) {
requirements.push('- Create data fetching hook following web/service/use-*.ts patterns')
requirements.push('- Use useQuery with proper queryKey and enabled options')
requirements.push('- Export invalidation hook (useInvalidXxx) for cache management')
}
if (analysis.lineCount > 300) {
requirements.push('- Extract logical UI sections into separate components')
requirements.push('- Keep parent component focused on orchestration')
requirements.push('- Pass minimal props to child components')
}
if (analysis.hasModals >= 2) {
requirements.push('- Create unified modal state management')
requirements.push('- Consider extracting modals to separate file')
}
if (analysis.conditionalBlocks > 10) {
requirements.push('- Replace switch statements with lookup tables')
requirements.push('- Use early returns to reduce nesting')
requirements.push('- Extract complex boolean logic to named functions')
}
if (requirements.length === 0) {
requirements.push('- Maintain existing code structure')
requirements.push('- Focus on readability improvements')
}
return requirements.join('\n')
}
}
// ============================================================================
// Main Function
// ============================================================================
function showHelp() {
console.log(`
🔧 Component Refactor Tool - Generate refactoring prompts for AI assistants
Usage:
node refactor-component.js <component-path> [options]
pnpm refactor-component <component-path> [options]
Options:
--help Show this help message
--json Output analysis result as JSON (for programmatic use)
Examples:
# Analyze and generate refactoring prompt
pnpm refactor-component app/components/app/configuration/index.tsx
# Output as JSON
pnpm refactor-component app/components/tools/mcp/modal.tsx --json
Complexity Thresholds:
🟢 0-25: Simple (no refactoring needed)
🟡 26-50: Medium (consider minor refactoring)
🟠 51-75: Complex (should refactor)
🔴 76-100: Very Complex (must refactor)
For complete refactoring guidelines, see:
.claude/skills/component-refactoring/SKILL.md
`)
}
function main() {
const rawArgs = process.argv.slice(2)
let isJsonMode = false
const args = []
rawArgs.forEach((arg) => {
if (arg === '--json') {
isJsonMode = true
return
}
if (arg === '--help' || arg === '-h') {
showHelp()
process.exit(0)
}
args.push(arg)
})
if (args.length === 0) {
showHelp()
process.exit(1)
}
let componentPath = args[0]
let absolutePath = path.resolve(process.cwd(), componentPath)
if (!fs.existsSync(absolutePath)) {
console.error(`❌ Error: Path not found: ${componentPath}`)
process.exit(1)
}
if (fs.statSync(absolutePath).isDirectory()) {
const resolvedFile = resolveDirectoryEntry(absolutePath, componentPath)
if (resolvedFile) {
absolutePath = resolvedFile.absolutePath
componentPath = resolvedFile.componentPath
}
else {
const availableFiles = listAnalyzableFiles(absolutePath)
console.error(`❌ Error: Directory does not contain a recognizable entry file: ${componentPath}`)
if (availableFiles.length > 0) {
console.error(`\n Available files to analyze:`)
availableFiles.forEach(f => console.error(` - ${path.join(componentPath, f)}`))
console.error(`\n Please specify the exact file path, e.g.:`)
console.error(` pnpm refactor-component ${path.join(componentPath, availableFiles[0])}`)
}
process.exit(1)
}
}
const sourceCode = fs.readFileSync(absolutePath, 'utf-8')
const analyzer = new RefactorAnalyzer()
const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath)
// JSON output mode
if (isJsonMode) {
console.log(JSON.stringify(analysis, null, 2))
return
}
// Check if refactoring is needed
if (analysis.complexity <= 25 && analysis.lineCount <= 200) {
console.log(`
╔════════════════════════════════════════════════════════════════════════════╗
║ ✅ COMPONENT IS WELL-STRUCTURED ║
╚════════════════════════════════════════════════════════════════════════════╝
📍 Component: ${analysis.name}
📂 Path: ${analysis.path}
📊 Metrics:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Complexity: ${analysis.complexity}/100 🟢 Simple
Lines: ${analysis.lineCount} ✓ Within limits
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This component has good structure. No immediate refactoring needed.
You can proceed with testing using: pnpm analyze-component ${componentPath}
`)
return
}
// Build refactoring prompt
const builder = new RefactorPromptBuilder()
const prompt = builder.build(analysis)
console.log(prompt)
// Copy to clipboard (macOS)
try {
const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' })
if (checkPbcopy.status !== 0)
return
const copyContent = extractCopyContent(prompt)
if (!copyContent)
return
const result = spawnSync('pbcopy', [], {
input: copyContent,
encoding: 'utf-8',
})
if (result.status === 0) {
console.log('\n📋 Refactoring prompt copied to clipboard!')
console.log(' Paste it in your AI assistant:')
console.log(' - Cursor: Cmd+L (Chat) or Cmd+I (Composer)')
console.log(' - GitHub Copilot Chat: Cmd+I')
console.log(' - Or any other AI coding tool\n')
}
}
catch {
// pbcopy failed, but don't break the script
}
}
// ============================================================================
// Run
// ============================================================================
main()
+1 -1
View File
@@ -33,7 +33,7 @@ pnpm test path/to/file.spec.tsx
- **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`, `next/image`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently.
- **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec.
- **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`.
- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
- **Script utilities**: `web/scripts/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
- `pnpm analyze-component <path>` - Analyze and generate test prompt
- `pnpm analyze-component <path> --json` - Output analysis as JSON
- `pnpm analyze-component <path> --review` - Generate test review prompt