diff --git a/.claude/skills/component-refactoring/SKILL.md b/.claude/skills/component-refactoring/SKILL.md new file mode 100644 index 0000000000..ea695ea442 --- /dev/null +++ b/.claude/skills/component-refactoring/SKILL.md @@ -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 + +# Output refactoring analysis as JSON +pnpm refactor-component --json + +# Generate testing prompt (after refactoring) +pnpm analyze-component + +# Output testing analysis as JSON +pnpm analyze-component --json +``` + +### Complexity Analysis + +```bash +# Analyze component complexity +pnpm analyze-component --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-.ts`. + +```typescript +// āŒ Before: Complex state logic in component +const Configuration: FC = () => { + const [modelConfig, setModelConfig] = useState(...) + const [datasetConfigs, setDatasetConfigs] = useState(...) + const [completionParams, setCompletionParams] = useState({}) + + // 50+ lines of state management logic... + + return
...
+} + +// āœ… After: Extract to custom hook +// hooks/use-model-config.ts +export const useModelConfig = (appId: string) => { + const [modelConfig, setModelConfig] = useState(...) + const [completionParams, setCompletionParams] = useState({}) + + // Related state management logic here + + return { modelConfig, setModelConfig, completionParams, setCompletionParams } +} + +// Component becomes cleaner +const Configuration: FC = () => { + const { modelConfig, setModelConfig } = useModelConfig(appId) + return
...
+} +``` + +**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 ( +
+ {/* 100 lines of header UI */} + {/* 100 lines of operations UI */} + {/* 100 lines of modals */} +
+ ) +} + +// āœ… 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 ( +
+ + + setShowModal(null)} /> +
+ ) +} +``` + +**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 + case LanguagesSupported[7]: + return + default: + return + } + } + 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 +}, [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(`/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(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 ... +} +``` + +## 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 ... + +// āœ… After: Split into domain-specific contexts + + + + {children} + + + +``` + +**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// + ā”œā”€ā”€ 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 +``` + +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 --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 + +# If complexity < 25 and lines < 200, you'll see: +# āœ… COMPONENT IS WELL-STRUCTURED + +# For detailed metrics: +pnpm analyze-component --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 diff --git a/.claude/skills/component-refactoring/references/complexity-patterns.md b/.claude/skills/component-refactoring/references/complexity-patterns.md new file mode 100644 index 0000000000..5a0a268f38 --- /dev/null +++ b/.claude/skills/component-refactoring/references/complexity-patterns.md @@ -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 + case LanguagesSupported[7]: + return + default: + return + } + } + if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { + switch (locale) { + case LanguagesSupported[1]: + return + case LanguagesSupported[7]: + return + default: + return + } + } + 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.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 +}, [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
...
+} +``` + +**After** (complexity: lower): + +```typescript +// Extract to hook or utility +const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState) => { + 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
...
+} +``` + +## 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 | diff --git a/.claude/skills/component-refactoring/references/component-splitting.md b/.claude/skills/component-refactoring/references/component-splitting.md new file mode 100644 index 0000000000..78a3389100 --- /dev/null +++ b/.claude/skills/component-refactoring/references/component-splitting.md @@ -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 && }` 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 ( +
+ {/* Header Section - 50 lines */} +
+

{t('configuration.title')}

+
+ {isAdvancedMode && Advanced} + + +
+
+ + {/* Config Section - 200 lines */} +
+ +
+ + {/* Debug Section - 150 lines */} +
+ +
+ + {/* Modals Section - 100 lines */} + {showSelectDataSet && } + {showHistoryModal && } + {showUseGPT4Confirm && } +
+ ) +} + +// āœ… 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 = ({ + isAdvancedMode, + onPublish, +}) => { + const { t } = useTranslation() + + return ( +
+

{t('configuration.title')}

+
+ {isAdvancedMode && Advanced} + + +
+
+ ) +} + +// index.tsx (orchestration only) +const ConfigurationPage = () => { + const { modelConfig, setModelConfig } = useModelConfig() + const { activeModal, openModal, closeModal } = useModalState() + + return ( +
+ + + {!isMobile && ( + + )} + +
+ ) +} +``` + +### Strategy 2: Conditional Block Extraction + +Extract large conditional rendering blocks. + +```typescript +// āŒ Before: Large conditional blocks +const AppInfo = () => { + return ( +
+ {expand ? ( +
+ {/* 100 lines of expanded view */} +
+ ) : ( +
+ {/* 50 lines of collapsed view */} +
+ )} +
+ ) +} + +// āœ… After: Separate view components +const AppInfoExpanded: FC = ({ appDetail, onAction }) => { + return ( +
+ {/* Clean, focused expanded view */} +
+ ) +} + +const AppInfoCollapsed: FC = ({ appDetail, onAction }) => { + return ( +
+ {/* Clean, focused collapsed view */} +
+ ) +} + +const AppInfo = () => { + return ( +
+ {expand + ? + : + } +
+ ) +} +``` + +### 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 ( +
+ {/* Main content */} + + {showEdit && setShowEdit(false)} />} + {showDuplicate && setShowDuplicate(false)} />} + {showDelete && setShowDelete(false)} />} + {showSwitch && } +
+ ) +} + +// āœ… 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 = ({ + appDetail, + activeModal, + onClose, + onSuccess, +}) => { + const handleEdit = async (data) => { /* logic */ } + const handleDuplicate = async (data) => { /* logic */ } + const handleDelete = async () => { /* logic */ } + + return ( + <> + {activeModal === 'edit' && ( + + )} + {activeModal === 'duplicate' && ( + + )} + {activeModal === 'delete' && ( + + )} + {activeModal === 'switch' && ( + + )} + + ) +} + +// Parent component +const AppInfo = () => { + const { activeModal, openModal, closeModal } = useModalState() + + return ( +
+ {/* Main content with openModal triggers */} + + + +
+ ) +} +``` + +### Strategy 4: List Item Extraction + +Extract repeated item rendering. + +```typescript +// āŒ Before: Inline item rendering +const OperationsList = () => { + return ( +
+ {operations.map(op => ( +
+ {op.icon} + {op.title} + {op.description} + + {op.badge && {op.badge}} + {/* More complex rendering... */} +
+ ))} +
+ ) +} + +// āœ… After: Extracted item component +interface OperationItemProps { + operation: Operation + onAction: (id: string) => void +} + +const OperationItem: FC = ({ operation, onAction }) => { + return ( +
+ {operation.icon} + {operation.title} + {operation.description} + + {operation.badge && {operation.badge}} +
+ ) +} + +const OperationsList = () => { + const handleAction = useCallback((id: string) => { + const op = operations.find(o => o.id === id) + op?.onClick() + }, [operations]) + + return ( +
+ {operations.map(op => ( + + ))} +
+ ) +} +``` + +## 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 + + +// āœ… Good: Destructure to minimum required + +``` + +### Callback Props Pattern + +Use callbacks for child-to-parent communication: + +```typescript +// Parent +const Parent = () => { + const [value, setValue] = useState('') + + return ( + + ) +} + +// Child +interface ChildProps { + value: string + onChange: (value: string) => void + onSubmit: () => void +} + +const Child: FC = ({ value, onChange, onSubmit }) => { + return ( +
+ onChange(e.target.value)} /> + +
+ ) +} +``` + +### Render Props for Flexibility + +When sub-components need parent context: + +```typescript +interface ListProps { + items: T[] + renderItem: (item: T, index: number) => React.ReactNode + renderEmpty?: () => React.ReactNode +} + +function List({ items, renderItem, renderEmpty }: ListProps) { + if (items.length === 0 && renderEmpty) { + return <>{renderEmpty()} + } + + return ( +
+ {items.map((item, index) => renderItem(item, index))} +
+ ) +} + +// Usage + } + renderEmpty={() => } +/> +``` diff --git a/.claude/skills/component-refactoring/references/hook-extraction.md b/.claude/skills/component-refactoring/references/hook-extraction.md new file mode 100644 index 0000000000..a8d75deffd --- /dev/null +++ b/.claude/skills/component-refactoring/references/hook-extraction.md @@ -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(...) +const [completionParams, setCompletionParams] = useState({}) +const [modelModeType, setModelModeType] = useState(...) + +// 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 + 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({ + provider: 'langgenius/openai/openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.unset, + // ... default values + ...initialConfig, + }) + + const [completionParams, setCompletionParams] = useState({}) + + 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(...) + // ... 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(`/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
...
+} +``` + +### 2. Form State Hook + +```typescript +// Pattern: Form state + validation + submission +export const useConfigForm = (initialValues: ConfigFormValues) => { + const [values, setValues] = useState(initialValues) + const [errors, setErrors] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const validate = useCallback(() => { + const newErrors: Record = {} + 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) => { + 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(null) + const [modalData, setModalData] = useState(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') + }) +}) +``` diff --git a/.claude/skills/frontend-testing/SKILL.md b/.claude/skills/frontend-testing/SKILL.md index 65602c92eb..dd9677a78e 100644 --- a/.claude/skills/frontend-testing/SKILL.md +++ b/.claude/skills/frontend-testing/SKILL.md @@ -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. diff --git a/web/package.json b/web/package.json index 0d52fc63dd..fe6b8ec9f7 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/testing/analyze-component.js b/web/scripts/analyze-component.js similarity index 64% rename from web/testing/analyze-component.js rename to web/scripts/analyze-component.js index 3f70f3d1ec..8b01744b3d 100755 --- a/web/testing/analyze-component.js +++ b/web/scripts/analyze-component.js @@ -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 diff --git a/web/scripts/component-analyzer.js b/web/scripts/component-analyzer.js new file mode 100644 index 0000000000..c53b652bc2 --- /dev/null +++ b/web/scripts/component-analyzer.js @@ -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' +} diff --git a/web/scripts/refactor-component.js b/web/scripts/refactor-component.js new file mode 100644 index 0000000000..f890540515 --- /dev/null +++ b/web/scripts/refactor-component.js @@ -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-.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 [options] + pnpm refactor-component [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() diff --git a/web/testing/testing.md b/web/testing/testing.md index 08fc716cf3..1d578ae634 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -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 ` - Analyze and generate test prompt - `pnpm analyze-component --json` - Output analysis as JSON - `pnpm analyze-component --review` - Generate test review prompt