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
parent c3bb95d71d
commit f2555b0bb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2687 additions and 470 deletions

View File

@ -0,0 +1,483 @@
---
name: component-refactoring
description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
---
# Dify Component Refactoring Skill
Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below.
> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing.
## Quick Reference
### Commands (run from `web/`)
Use paths relative to `web/` (e.g., `app/components/...`).
Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics.
```bash
cd web
# Generate refactoring prompt
pnpm refactor-component <path>
# Output refactoring analysis as JSON
pnpm refactor-component <path> --json
# Generate testing prompt (after refactoring)
pnpm analyze-component <path>
# Output testing analysis as JSON
pnpm analyze-component <path> --json
```
### Complexity Analysis
```bash
# Analyze component complexity
pnpm analyze-component <path> --json
# Key metrics to check:
# - complexity: normalized score 0-100 (target < 50)
# - maxComplexity: highest single function complexity
# - lineCount: total lines (target < 300)
```
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
## Core Refactoring Patterns
### Pattern 1: Extract Custom Hooks
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`.
```typescript
// ❌ Before: Complex state logic in component
const Configuration: FC = () => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// 50+ lines of state management logic...
return <div>...</div>
}
// ✅ After: Extract to custom hook
// hooks/use-model-config.ts
export const useModelConfig = (appId: string) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// Related state management logic here
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
}
// Component becomes cleaner
const Configuration: FC = () => {
const { modelConfig, setModelConfig } = useModelConfig(appId)
return <div>...</div>
}
```
**Dify Examples**:
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
- `web/app/components/app/configuration/debug/hooks.tsx`
- `web/app/components/workflow/hooks/use-workflow.ts`
### Pattern 2: Extract Sub-Components
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory.
```typescript
// ❌ Before: Monolithic JSX with multiple sections
const AppInfo = () => {
return (
<div>
{/* 100 lines of header UI */}
{/* 100 lines of operations UI */}
{/* 100 lines of modals */}
</div>
)
}
// ✅ After: Split into focused components
// app-info/
// ├── index.tsx (orchestration only)
// ├── app-header.tsx (header UI)
// ├── app-operations.tsx (operations UI)
// └── app-modals.tsx (modal management)
const AppInfo = () => {
const { showModal, setShowModal } = useAppInfoModals()
return (
<div>
<AppHeader appDetail={appDetail} />
<AppOperations onAction={handleAction} />
<AppModals show={showModal} onClose={() => setShowModal(null)} />
</div>
)
}
```
**Dify Examples**:
- `web/app/components/app/configuration/` directory structure
- `web/app/components/workflow/nodes/` per-node organization
### Pattern 3: Simplify Conditional Logic
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
```typescript
// ❌ Before: Deeply nested conditionals
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh />
case LanguagesSupported[7]:
return <TemplateChatJa />
default:
return <TemplateChatEn />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
// Another 15 lines...
}
// More conditions...
}, [appDetail, locale])
// ✅ After: Use lookup tables + early returns
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
// ...
},
}
const Template = useMemo(() => {
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
if (!modeTemplates) return null
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
### Pattern 4: Extract API/Data Logic
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. Project is migrating from SWR to React Query.
```typescript
// ❌ Before: API logic in component
const MCPServiceCard = () => {
const [basicAppConfig, setBasicAppConfig] = useState({})
useEffect(() => {
if (isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
// More API-related logic...
}
// ✅ After: Extract to data hook using React Query
// use-app-config.ts
import { useQuery } from '@tanstack/react-query'
import { get } from '@/service/base'
const NAME_SPACE = 'appConfig'
export const useAppConfig = (appId: string, isBasicApp: boolean) => {
return useQuery({
enabled: isBasicApp && !!appId,
queryKey: [NAME_SPACE, 'detail', appId],
queryFn: () => get<AppDetailResponse>(`/apps/${appId}`),
select: data => data?.model_config || {},
})
}
// Component becomes cleaner
const MCPServiceCard = () => {
const { data: config, isLoading } = useAppConfig(appId, isBasicApp)
// UI only
}
```
**React Query Best Practices in Dify**:
- Define `NAME_SPACE` for query key organization
- Use `enabled` option for conditional fetching
- Use `select` for data transformation
- Export invalidation hooks: `useInvalidXxx`
**Dify Examples**:
- `web/service/use-workflow.ts`
- `web/service/use-common.ts`
- `web/service/knowledge/use-dataset.ts`
- `web/service/knowledge/use-document.ts`
### Pattern 5: Extract Modal/Dialog Management
**When**: Component manages multiple modals with complex open/close states.
**Dify Convention**: Modals should be extracted with their state management.
```typescript
// ❌ Before: Multiple modal states in component
const AppInfo = () => {
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState(false)
const [showImportDSLModal, setShowImportDSLModal] = useState(false)
// 5+ more modal states...
}
// ✅ After: Extract to modal management hook
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
const useAppInfoModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
closeModal,
isOpen: (type: ModalType) => activeModal === type,
}
}
```
### Pattern 6: Extract Form Logic
**When**: Complex form validation, submission handling, or field transformation.
**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`.
```typescript
// ✅ Use existing form infrastructure
import { useAppForm } from '@/app/components/base/form'
const ConfigForm = () => {
const form = useAppForm({
defaultValues: { name: '', description: '' },
onSubmit: handleSubmit,
})
return <form.Provider>...</form.Provider>
}
```
## Dify-Specific Refactoring Guidelines
### 1. Context Provider Extraction
**When**: Component provides complex context values with multiple states.
```typescript
// ❌ Before: Large context value object
const value = {
appId, isAPIKeySet, isTrailFinished, mode, modelModeType,
promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall,
// 50+ more properties...
}
return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
// ✅ After: Split into domain-specific contexts
<ModelConfigProvider value={modelConfigValue}>
<DatasetConfigProvider value={datasetConfigValue}>
<UIConfigProvider value={uiConfigValue}>
{children}
</UIConfigProvider>
</DatasetConfigProvider>
</ModelConfigProvider>
```
**Dify Reference**: `web/context/` directory structure
### 2. Workflow Node Components
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
**Conventions**:
- Keep node logic in `use-interactions.ts`
- Extract panel UI to separate files
- Use `_base` components for common patterns
```
nodes/<node-type>/
├── index.tsx # Node registration
├── node.tsx # Node visual component
├── panel.tsx # Configuration panel
├── use-interactions.ts # Node-specific hooks
└── types.ts # Type definitions
```
### 3. Configuration Components
**When**: Refactoring app configuration components.
**Conventions**:
- Separate config sections into subdirectories
- Use existing patterns from `web/app/components/app/configuration/`
- Keep feature toggles in dedicated components
### 4. Tool/Plugin Components
**When**: Refactoring tool-related components (`web/app/components/tools/`).
**Conventions**:
- Follow existing modal patterns
- Use service hooks from `web/service/use-tools.ts`
- Keep provider-specific logic isolated
## Refactoring Workflow
### Step 1: Generate Refactoring Prompt
```bash
pnpm refactor-component <path>
```
This command will:
- Analyze component complexity and features
- Identify specific refactoring actions needed
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
- Provide detailed requirements based on detected patterns
### Step 2: Analyze Details
```bash
pnpm analyze-component <path> --json
```
Identify:
- Total complexity score
- Max function complexity
- Line count
- Features detected (state, effects, API, etc.)
### Step 3: Plan
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
### Step 4: Execute Incrementally
1. **Extract one piece at a time**
2. **Run lint, type-check, and tests after each extraction**
3. **Verify functionality before next step**
```
For each extraction:
┌────────────────────────────────────────┐
│ 1. Extract code │
│ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check:tsgo │
│ 4. Run: pnpm test │
│ 5. Test functionality manually │
│ 6. PASS? → Next extraction │
│ FAIL? → Fix before continuing │
└────────────────────────────────────────┘
```
### Step 5: Verify
After refactoring:
```bash
# Re-run refactor command to verify improvements
pnpm refactor-component <path>
# If complexity < 25 and lines < 200, you'll see:
# ✅ COMPONENT IS WELL-STRUCTURED
# For detailed metrics:
pnpm analyze-component <path> --json
# Target metrics:
# - complexity < 50
# - lineCount < 300
# - maxComplexity < 30
```
## Common Mistakes to Avoid
### ❌ Over-Engineering
```typescript
// ❌ Too many tiny hooks
const useButtonText = () => useState('Click')
const useButtonDisabled = () => useState(false)
const useButtonLoading = () => useState(false)
// ✅ Cohesive hook with related state
const useButtonState = () => {
const [text, setText] = useState('Click')
const [disabled, setDisabled] = useState(false)
const [loading, setLoading] = useState(false)
return { text, setText, disabled, setDisabled, loading, setLoading }
}
```
### ❌ Breaking Existing Patterns
- Follow existing directory structures
- Maintain naming conventions
- Preserve export patterns for compatibility
### ❌ Premature Abstraction
- Only extract when there's clear complexity benefit
- Don't create abstractions for single-use code
- Keep refactored code in the same domain area
## References
### Dify Codebase Examples
- **Hook extraction**: `web/app/components/app/configuration/hooks/`
- **Component splitting**: `web/app/components/app/configuration/`
- **Service hooks**: `web/service/use-*.ts`
- **Workflow patterns**: `web/app/components/workflow/hooks/`
- **Form patterns**: `web/app/components/base/form/`
### Related Skills
- `frontend-testing` - For testing refactored components
- `web/testing/testing.md` - Testing specification

View File

@ -0,0 +1,493 @@
# Complexity Reduction Patterns
This document provides patterns for reducing cognitive complexity in Dify React components.
## Understanding Complexity
### SonarJS Cognitive Complexity
The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
- **Total Complexity**: Sum of all functions' complexity in the file
- **Max Complexity**: Highest single function complexity
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
**Before** (complexity: ~15):
```typescript
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} />
default:
return <TemplateChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
// Similar pattern...
}
return null
}, [appDetail, locale])
```
**After** (complexity: ~3):
```typescript
// Define lookup table outside component
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
[LanguagesSupported[7]]: TemplateAdvancedChatJa,
default: TemplateAdvancedChatEn,
},
[AppModeEnum.WORKFLOW]: {
[LanguagesSupported[1]]: TemplateWorkflowZh,
[LanguagesSupported[7]]: TemplateWorkflowJa,
default: TemplateWorkflowEn,
},
// ...
}
// Clean component logic
const Template = useMemo(() => {
if (!appDetail?.mode) return null
const templates = TEMPLATE_MAP[appDetail.mode]
if (!templates) return null
const TemplateComponent = templates[locale] ?? templates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
## Pattern 2: Use Early Returns
**Before** (complexity: ~10):
```typescript
const handleSubmit = () => {
if (isValid) {
if (hasChanges) {
if (isConnected) {
submitData()
} else {
showConnectionError()
}
} else {
showNoChangesMessage()
}
} else {
showValidationError()
}
}
```
**After** (complexity: ~4):
```typescript
const handleSubmit = () => {
if (!isValid) {
showValidationError()
return
}
if (!hasChanges) {
showNoChangesMessage()
return
}
if (!isConnected) {
showConnectionError()
return
}
submitData()
}
```
## Pattern 3: Extract Complex Conditions
**Before** (complexity: high):
```typescript
const canPublish = (() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return true
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
return false
return true
}
return true
}
return !promptEmpty
})()
```
**After** (complexity: lower):
```typescript
// Extract to named functions
const canPublishInCompletionMode = () => !promptEmpty
const canPublishInChatMode = () => {
if (!isAdvancedMode) return true
if (modelModeType !== ModelModeType.completion) return true
return hasSetBlockStatus.history && hasSetBlockStatus.query
}
// Clean main logic
const canPublish = mode === AppModeEnum.COMPLETION
? canPublishInCompletionMode()
: canPublishInChatMode()
```
## Pattern 4: Replace Chained Ternaries
**Before** (complexity: ~5):
```typescript
const statusText = serverActivated
? t('status.running')
: serverPublished
? t('status.inactive')
: appUnpublished
? t('status.unpublished')
: t('status.notConfigured')
```
**After** (complexity: ~2):
```typescript
const getStatusText = () => {
if (serverActivated) return t('status.running')
if (serverPublished) return t('status.inactive')
if (appUnpublished) return t('status.unpublished')
return t('status.notConfigured')
}
const statusText = getStatusText()
```
Or use lookup:
```typescript
const STATUS_TEXT_MAP = {
running: 'status.running',
inactive: 'status.inactive',
unpublished: 'status.unpublished',
notConfigured: 'status.notConfigured',
} as const
const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => {
if (serverActivated) return 'running'
if (serverPublished) return 'inactive'
if (appUnpublished) return 'unpublished'
return 'notConfigured'
}
const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
```
## Pattern 5: Flatten Nested Loops
**Before** (complexity: high):
```typescript
const processData = (items: Item[]) => {
const results: ProcessedItem[] = []
for (const item of items) {
if (item.isValid) {
for (const child of item.children) {
if (child.isActive) {
for (const prop of child.properties) {
if (prop.value !== null) {
results.push({
itemId: item.id,
childId: child.id,
propValue: prop.value,
})
}
}
}
}
}
}
return results
}
```
**After** (complexity: lower):
```typescript
// Use functional approach
const processData = (items: Item[]) => {
return items
.filter(item => item.isValid)
.flatMap(item =>
item.children
.filter(child => child.isActive)
.flatMap(child =>
child.properties
.filter(prop => prop.value !== null)
.map(prop => ({
itemId: item.id,
childId: child.id,
propValue: prop.value,
}))
)
)
}
```
## Pattern 6: Extract Event Handler Logic
**Before** (complexity: high in component):
```typescript
const Component = () => {
const handleSelect = (data: DataSet[]) => {
if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
let newDatasets = data
if (data.find(item => !item.name)) {
const newSelected = produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const newItem = dataSets.find(i => i.id === item.id)
if (newItem)
draft[index] = newItem
}
})
})
setDataSets(newSelected)
newDatasets = newSelected
}
else {
setDataSets(data)
}
hideSelectDataSet()
// 40 more lines of logic...
}
return <div>...</div>
}
```
**After** (complexity: lower):
```typescript
// Extract to hook or utility
const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[]>) => {
const normalizeSelection = (data: DataSet[]) => {
const hasUnloadedItem = data.some(item => !item.name)
if (!hasUnloadedItem) return data
return produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const existing = dataSets.find(i => i.id === item.id)
if (existing) draft[index] = existing
}
})
})
}
const hasSelectionChanged = (newData: DataSet[]) => {
return !isEqual(
newData.map(item => item.id),
dataSets.map(item => item.id)
)
}
return { normalizeSelection, hasSelectionChanged }
}
// Component becomes cleaner
const Component = () => {
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
const handleSelect = (data: DataSet[]) => {
if (!hasSelectionChanged(data)) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
const normalized = normalizeSelection(data)
setDataSets(normalized)
hideSelectDataSet()
}
return <div>...</div>
}
```
## Pattern 7: Reduce Boolean Logic Complexity
**Before** (complexity: ~8):
```typescript
const toggleDisabled = hasInsufficientPermissions
|| appUnpublished
|| missingStartNode
|| triggerModeDisabled
|| (isAdvancedApp && !currentWorkflow?.graph)
|| (isBasicApp && !basicAppConfig.updated_at)
```
**After** (complexity: ~3):
```typescript
// Extract meaningful boolean functions
const isAppReady = () => {
if (isAdvancedApp) return !!currentWorkflow?.graph
return !!basicAppConfig.updated_at
}
const hasRequiredPermissions = () => {
return isCurrentWorkspaceEditor && !hasInsufficientPermissions
}
const canToggle = () => {
if (!hasRequiredPermissions()) return false
if (!isAppReady()) return false
if (missingStartNode) return false
if (triggerModeDisabled) return false
return true
}
const toggleDisabled = !canToggle()
```
## Pattern 8: Simplify useMemo/useCallback Dependencies
**Before** (complexity: multiple recalculations):
```typescript
const payload = useMemo(() => {
let parameters: Parameter[] = []
let outputParameters: OutputParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
outputParameters = (outputs || []).map((item) => ({
name: item.variable,
description: '',
type: item.value_type,
}))
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => ({
// Complex transformation...
}))
outputParameters = (outputs || []).map((item) => ({
// Complex transformation...
}))
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
// ...more fields
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
```
**After** (complexity: separated concerns):
```typescript
// Separate transformations
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
return useMemo(() => {
if (!published) {
return inputs.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
if (!detail?.tool) return []
return inputs.map(item => ({
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
}))
}, [inputs, detail, published])
}
// Component uses hook
const parameters = useParameterTransform(inputs, detail, published)
const outputParameters = useOutputTransform(outputs, detail, published)
const payload = useMemo(() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}), [detail, icon, name, parameters, outputParameters])
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | ≤ 3 levels |
| Conditional Chains | ≤ 3 conditions |

View File

@ -0,0 +1,477 @@
# Component Splitting Patterns
This document provides detailed guidance on splitting large components into smaller, focused components in Dify.
## When to Split Components
Split a component when you identify:
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
1. **Repeated patterns** - Similar UI structures used multiple times
1. **300+ lines** - Component exceeds manageable size
1. **Modal clusters** - Multiple modals rendered in one component
## Splitting Strategies
### Strategy 1: Section-Based Splitting
Identify visual sections and extract each as a component.
```typescript
// ❌ Before: Monolithic component (500+ lines)
const ConfigurationPage = () => {
return (
<div>
{/* Header Section - 50 lines */}
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher ... />
</div>
</div>
{/* Config Section - 200 lines */}
<div className="config">
<Config />
</div>
{/* Debug Section - 150 lines */}
<div className="debug">
<Debug ... />
</div>
{/* Modals Section - 100 lines */}
{showSelectDataSet && <SelectDataSet ... />}
{showHistoryModal && <EditHistoryModal ... />}
{showUseGPT4Confirm && <Confirm ... />}
</div>
)
}
// ✅ After: Split into focused components
// configuration/
// ├── index.tsx (orchestration)
// ├── configuration-header.tsx
// ├── configuration-content.tsx
// ├── configuration-debug.tsx
// └── configuration-modals.tsx
// configuration-header.tsx
interface ConfigurationHeaderProps {
isAdvancedMode: boolean
onPublish: () => void
}
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({
isAdvancedMode,
onPublish,
}) => {
const { t } = useTranslation()
return (
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher onPublish={onPublish} />
</div>
</div>
)
}
// index.tsx (orchestration only)
const ConfigurationPage = () => {
const { modelConfig, setModelConfig } = useModelConfig()
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
<ConfigurationHeader
isAdvancedMode={isAdvancedMode}
onPublish={handlePublish}
/>
<ConfigurationContent
modelConfig={modelConfig}
onConfigChange={setModelConfig}
/>
{!isMobile && (
<ConfigurationDebug
inputs={inputs}
onSetting={handleSetting}
/>
)}
<ConfigurationModals
activeModal={activeModal}
onClose={closeModal}
/>
</div>
)
}
```
### Strategy 2: Conditional Block Extraction
Extract large conditional rendering blocks.
```typescript
// ❌ Before: Large conditional blocks
const AppInfo = () => {
return (
<div>
{expand ? (
<div className="expanded">
{/* 100 lines of expanded view */}
</div>
) : (
<div className="collapsed">
{/* 50 lines of collapsed view */}
</div>
)}
</div>
)
}
// ✅ After: Separate view components
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
return (
<div className="expanded">
{/* Clean, focused expanded view */}
</div>
)
}
const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
return (
<div className="collapsed">
{/* Clean, focused collapsed view */}
</div>
)
}
const AppInfo = () => {
return (
<div>
{expand
? <AppInfoExpanded appDetail={appDetail} onAction={handleAction} />
: <AppInfoCollapsed appDetail={appDetail} onAction={handleAction} />
}
</div>
)
}
```
### Strategy 3: Modal Extraction
Extract modals with their trigger logic.
```typescript
// ❌ Before: Multiple modals in one component
const AppInfo = () => {
const [showEdit, setShowEdit] = useState(false)
const [showDuplicate, setShowDuplicate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showSwitch, setShowSwitch] = useState(false)
const onEdit = async (data) => { /* 20 lines */ }
const onDuplicate = async (data) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main content */}
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
{showSwitch && <SwitchModal ... />}
</div>
)
}
// ✅ After: Modal manager component
// app-info-modals.tsx
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null
interface AppInfoModalsProps {
appDetail: AppDetail
activeModal: ModalType
onClose: () => void
onSuccess: () => void
}
const AppInfoModals: FC<AppInfoModalsProps> = ({
appDetail,
activeModal,
onClose,
onSuccess,
}) => {
const handleEdit = async (data) => { /* logic */ }
const handleDuplicate = async (data) => { /* logic */ }
const handleDelete = async () => { /* logic */ }
return (
<>
{activeModal === 'edit' && (
<EditModal
appDetail={appDetail}
onConfirm={handleEdit}
onClose={onClose}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateModal
appDetail={appDetail}
onConfirm={handleDuplicate}
onClose={onClose}
/>
)}
{activeModal === 'delete' && (
<DeleteConfirm
onConfirm={handleDelete}
onClose={onClose}
/>
)}
{activeModal === 'switch' && (
<SwitchModal
appDetail={appDetail}
onClose={onClose}
/>
)}
</>
)
}
// Parent component
const AppInfo = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main content with openModal triggers */}
<Button onClick={() => openModal('edit')}>Edit</Button>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
onClose={closeModal}
onSuccess={handleSuccess}
/>
</div>
)
}
```
### Strategy 4: List Item Extraction
Extract repeated item rendering.
```typescript
// ❌ Before: Inline item rendering
const OperationsList = () => {
return (
<div>
{operations.map(op => (
<div key={op.id} className="operation-item">
<span className="icon">{op.icon}</span>
<span className="title">{op.title}</span>
<span className="description">{op.description}</span>
<button onClick={() => op.onClick()}>
{op.actionLabel}
</button>
{op.badge && <Badge>{op.badge}</Badge>}
{/* More complex rendering... */}
</div>
))}
</div>
)
}
// ✅ After: Extracted item component
interface OperationItemProps {
operation: Operation
onAction: (id: string) => void
}
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => {
return (
<div className="operation-item">
<span className="icon">{operation.icon}</span>
<span className="title">{operation.title}</span>
<span className="description">{operation.description}</span>
<button onClick={() => onAction(operation.id)}>
{operation.actionLabel}
</button>
{operation.badge && <Badge>{operation.badge}</Badge>}
</div>
)
}
const OperationsList = () => {
const handleAction = useCallback((id: string) => {
const op = operations.find(o => o.id === id)
op?.onClick()
}, [operations])
return (
<div>
{operations.map(op => (
<OperationItem
key={op.id}
operation={op}
onAction={handleAction}
/>
))}
</div>
)
}
```
## Directory Structure Patterns
### Pattern A: Flat Structure (Simple Components)
For components with 2-3 sub-components:
```
component-name/
├── index.tsx # Main component
├── sub-component-a.tsx
├── sub-component-b.tsx
└── types.ts # Shared types
```
### Pattern B: Nested Structure (Complex Components)
For components with many sub-components:
```
component-name/
├── index.tsx # Main orchestration
├── types.ts # Shared types
├── hooks/
│ ├── use-feature-a.ts
│ └── use-feature-b.ts
├── components/
│ ├── header/
│ │ └── index.tsx
│ ├── content/
│ │ └── index.tsx
│ └── modals/
│ └── index.tsx
└── utils/
└── helpers.ts
```
### Pattern C: Feature-Based Structure (Dify Standard)
Following Dify's existing patterns:
```
configuration/
├── index.tsx # Main page component
├── base/ # Base/shared components
│ ├── feature-panel/
│ ├── group-name/
│ └── operation-btn/
├── config/ # Config section
│ ├── index.tsx
│ ├── agent/
│ └── automatic/
├── dataset-config/ # Dataset section
│ ├── index.tsx
│ ├── card-item/
│ └── params-config/
├── debug/ # Debug section
│ ├── index.tsx
│ └── hooks.tsx
└── hooks/ # Shared hooks
└── use-advanced-prompt-config.ts
```
## Props Design
### Minimal Props Principle
Pass only what's needed:
```typescript
// ❌ Bad: Passing entire objects when only some fields needed
<ConfigHeader appDetail={appDetail} modelConfig={modelConfig} />
// ✅ Good: Destructure to minimum required
<ConfigHeader
appName={appDetail.name}
isAdvancedMode={modelConfig.isAdvanced}
onPublish={handlePublish}
/>
```
### Callback Props Pattern
Use callbacks for child-to-parent communication:
```typescript
// Parent
const Parent = () => {
const [value, setValue] = useState('')
return (
<Child
value={value}
onChange={setValue}
onSubmit={handleSubmit}
/>
)
}
// Child
interface ChildProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
}
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => {
return (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button onClick={onSubmit}>Submit</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
// Usage
<List
items={operations}
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
renderEmpty={() => <EmptyState message="No operations" />}
/>
```

View File

@ -0,0 +1,317 @@
# Hook Extraction Patterns
This document provides detailed guidance on extracting custom hooks from complex components in Dify.
## When to Extract Hooks
Extract a custom hook when you identify:
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
1. **Business logic** - Data transformations, validations, or calculations
1. **Reusable patterns** - Logic that appears in multiple components
## Extraction Process
### Step 1: Identify State Groups
Look for state variables that are logically related:
```typescript
// ❌ These belong together - extract to hook
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
const [modelModeType, setModelModeType] = useState<ModelModeType>(...)
// These are model-related state that should be in useModelConfig()
```
### Step 2: Identify Related Effects
Find effects that modify the grouped state:
```typescript
// ❌ These effects belong with the state above
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties.mode
if (mode) {
const newModelConfig = produce(modelConfig, (draft) => {
draft.mode = mode
})
setModelConfig(newModelConfig)
}
}
}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel])
```
### Step 3: Create the Hook
```typescript
// hooks/use-model-config.ts
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import { produce } from 'immer'
import { useEffect, useState } from 'react'
import { ModelModeType } from '@/types/app'
interface UseModelConfigParams {
initialConfig?: Partial<ModelConfig>
currModel?: { model_properties?: { mode?: ModelModeType } }
hasFetchedDetail: boolean
}
interface UseModelConfigReturn {
modelConfig: ModelConfig
setModelConfig: (config: ModelConfig) => void
completionParams: FormValue
setCompletionParams: (params: FormValue) => void
modelModeType: ModelModeType
}
export const useModelConfig = ({
initialConfig,
currModel,
hasFetchedDetail,
}: UseModelConfigParams): UseModelConfigReturn => {
const [modelConfig, setModelConfig] = useState<ModelConfig>({
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
// ... default values
...initialConfig,
})
const [completionParams, setCompletionParams] = useState<FormValue>({})
const modelModeType = modelConfig.mode
// Fill old app data missing model mode
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties?.mode
if (mode) {
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = mode
}))
}
}
}, [hasFetchedDetail, modelModeType, currModel])
return {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
}
}
```
### Step 4: Update Component
```typescript
// Before: 50+ lines of state management
const Configuration: FC = () => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects
}
// After: Clean component
const Configuration: FC = () => {
const {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
} = useModelConfig({
currModel,
hasFetchedDetail,
})
// Component now focuses on UI
}
```
## Naming Conventions
### Hook Names
- Use `use` prefix: `useModelConfig`, `useDatasetConfig`
- Be specific: `useAdvancedPromptConfig` not `usePrompt`
- Include domain: `useWorkflowVariables`, `useMCPServer`
### File Names
- Kebab-case: `use-model-config.ts`
- Place in `hooks/` subdirectory when multiple hooks exist
- Place alongside component for single-use hooks
### Return Type Names
- Suffix with `Return`: `UseModelConfigReturn`
- Suffix params with `Params`: `UseModelConfigParams`
## Common Hook Patterns in Dify
### 1. Data Fetching Hook (React Query)
```typescript
// Pattern: Use @tanstack/react-query for data fetching
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { get } from '@/service/base'
import { useInvalid } from '@/service/use-base'
const NAME_SPACE = 'appConfig'
// Query keys for cache management
export const appConfigQueryKeys = {
detail: (appId: string) => [NAME_SPACE, 'detail', appId] as const,
}
// Main data hook
export const useAppConfig = (appId: string) => {
return useQuery({
enabled: !!appId,
queryKey: appConfigQueryKeys.detail(appId),
queryFn: () => get<AppDetailResponse>(`/apps/${appId}`),
select: data => data?.model_config || null,
})
}
// Invalidation hook for refreshing data
export const useInvalidAppConfig = () => {
return useInvalid([NAME_SPACE])
}
// Usage in component
const Component = () => {
const { data: config, isLoading, error, refetch } = useAppConfig(appId)
const invalidAppConfig = useInvalidAppConfig()
const handleRefresh = () => {
invalidAppConfig() // Invalidates cache and triggers refetch
}
return <div>...</div>
}
```
### 2. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useConfigForm = (initialValues: ConfigFormValues) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name) newErrors.name = 'Name is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 3. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = 'edit' | 'delete' | 'duplicate' | null
export const useModalState = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<any>(null)
const openModal = useCallback((type: ModalType, data?: any) => {
setActiveModal(type)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
}
}
```
### 4. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
## Testing Extracted Hooks
After extraction, test hooks in isolation:
```typescript
// use-model-config.spec.ts
import { renderHook, act } from '@testing-library/react'
import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
act(() => {
result.current.setModelConfig({
...result.current.modelConfig,
model_id: 'gpt-4',
})
})
expect(result.current.modelConfig.model_id).toBe('gpt-4')
})
})
```

View File

@ -318,5 +318,5 @@ For more detailed information, refer to:
- `web/vitest.config.ts` - Vitest configuration
- `web/vitest.setup.ts` - Test environment setup
- `web/testing/analyze-component.js` - Component analysis tool
- `web/scripts/analyze-component.js` - Component analysis tool
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.

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",

View File

@ -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

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'
}

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()

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