From a23c31e9727c91f26ad791d4b078eb87bb6eb863 Mon Sep 17 00:00:00 2001 From: hyunseok-kim-f Date: Fri, 6 Mar 2026 20:44:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#153]=20=E2=9C=A8=20feat:=20cognitive-comp?= =?UTF-8?q?lexity=20ESLint=20=EB=A3=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 함수의 Cognitive Complexity가 임계값을 초과하면 라인별 점수 상세와 리팩토링 제안을 포함하여 보고하는 ESLint 룰을 추가합니다. - SonarSource Cognitive Complexity 스펙 기반 점수 계산 - 라인별 점수 기여도 상세 (한글 안내) - 6종 리팩토링 제안 (suggestions 옵션으로 활성화) Co-Authored-By: Claude Opus 4.6 --- packages/eslint-plugin/lib/index.js | 2 + .../lib/rules/cognitive-complexity.js | 594 ++++++++++++++++++ .../lib/rules/cognitive-complexity.test.js | 425 +++++++++++++ 3 files changed, 1021 insertions(+) create mode 100644 packages/eslint-plugin/lib/rules/cognitive-complexity.js create mode 100644 packages/eslint-plugin/lib/rules/cognitive-complexity.test.js diff --git a/packages/eslint-plugin/lib/index.js b/packages/eslint-plugin/lib/index.js index ad56814..779d480 100644 --- a/packages/eslint-plugin/lib/index.js +++ b/packages/eslint-plugin/lib/index.js @@ -1,4 +1,5 @@ import pkg from '../package.json' +import cognitiveComplexity from './rules/cognitive-complexity.js' import importServerOnly from './rules/import-server-only' import memoReactComponents from './rules/memo-react-components.js' import optimizeSvgComponents from './rules/optimize-svg-components.js' @@ -13,6 +14,7 @@ const plugin = { version: pkg.version, }, rules: { + 'cognitive-complexity': cognitiveComplexity, 'memo-react-components': memoReactComponents, 'optimize-svg-components': optimizeSvgComponents, 'prevent-default-import': preventDefaultImport, diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.js new file mode 100644 index 0000000..09dd650 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.js @@ -0,0 +1,594 @@ +/** + * @fileoverview Cognitive Complexity 분석 + 리팩토링 제안을 포함하는 ESLint 룰. + * + * SonarSource Cognitive Complexity 스펙 기반. + * eslint-plugin-sonarjs 호환 모드 (&&만 계산, ||/??/재귀 미계산). + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const STRUCTURAL_NODES = new Set([ + 'IfStatement', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'WhileStatement', + 'DoWhileStatement', + 'SwitchStatement', + 'CatchClause', + 'ConditionalExpression', +]) + +const NESTING_NODES = new Set(STRUCTURAL_NODES) + +const INCREASES_NESTING_NODES = new Set(STRUCTURAL_NODES) + +const FUNCTION_NODES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']) + +const isFunctionNode = (node) => FUNCTION_NODES.has(node.type) + +const isElseIf = (node) => + node.type === 'IfStatement' && node.parent?.type === 'IfStatement' && node.parent.alternate === node + +const getKindDescription = (node) => { + switch (node.type) { + case 'IfStatement': + return isElseIf(node) ? 'else if' : 'if' + case 'ForStatement': + return 'for' + case 'ForInStatement': + return 'for-in' + case 'ForOfStatement': + return 'for-of' + case 'WhileStatement': + return 'while' + case 'DoWhileStatement': + return 'do-while' + case 'SwitchStatement': + return 'switch' + case 'CatchClause': + return 'catch' + case 'ConditionalExpression': + return 'ternary' + default: + return node.type + } +} + +const toKorean = (description) => { + switch (description) { + case 'if': + return 'if문' + case 'else if': + return 'else if문' + case 'else': + return 'else문' + case 'for': + return 'for문' + case 'for-in': + return 'for-in문' + case 'for-of': + return 'for-of문' + case 'while': + return 'while문' + case 'do-while': + return 'do-while문' + case 'switch': + return 'switch문' + case 'catch': + return 'catch문' + case 'ternary': + return '삼항 연산자' + case 'break to label': + return '라벨 break문' + case 'continue to label': + return '라벨 continue문' + default: + if (description.startsWith('logical: ')) return description.slice(9) + ' 연산자' + return description + } +} + +// --------------------------------------------------------------------------- +// Complexity calculation (ESTree walker) +// --------------------------------------------------------------------------- + +/** + * 주어진 함수 본문의 cognitive complexity를 계산한다. + * @returns {{ score: number, increments: Array, maxNestingDepth: number }} + */ +const calculateComplexity = (bodyNode) => { + const increments = [] + let maxNestingDepth = 0 + + const walk = (node, nestingLevel) => { + if (!node || typeof node !== 'object') return + if (nestingLevel > maxNestingDepth) maxNestingDepth = nestingLevel + + // 중첩 함수는 부모 함수의 복잡도에 기여하지 않음 + if (isFunctionNode(node)) return + + // 논리 연산자 시퀀스 처리 + if (node.type === 'LogicalExpression' && node.operator === '&&') { + handleLogicalExpression(node, nestingLevel) + return + } + + // labeled break/continue + if ((node.type === 'BreakStatement' || node.type === 'ContinueStatement') && node.label) { + increments.push({ + line: node.loc.start.line, + type: 'structural', + value: 1, + nestingLevel, + description: `${node.type === 'BreakStatement' ? 'break' : 'continue'} to label`, + }) + return + } + + // 구조적/중첩 증분 노드 + if (STRUCTURAL_NODES.has(node.type)) { + handleStructuralNode(node, nestingLevel) + return + } + + // 그 외 자식 노드 탐색 + walkChildren(node, nestingLevel) + } + + const walkChildren = (node, nestingLevel) => { + for (const key of Object.keys(node)) { + if (key === 'parent') continue + const child = node[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item.type === 'string') { + walk(item, nestingLevel) + } + } + } else if (child && typeof child === 'object' && typeof child.type === 'string') { + walk(child, nestingLevel) + } + } + } + + const handleStructuralNode = (node, nestingLevel) => { + const line = node.loc.start.line + + // else if: structural +1만, nesting 증가 없음 + if (isElseIf(node)) { + increments.push({line, type: 'structural', value: 1, nestingLevel, description: 'else if'}) + + // else if의 else 분기 + if (node.alternate && node.alternate.type !== 'IfStatement') { + increments.push({ + line: node.alternate.loc.start.line, + type: 'structural', + value: 1, + nestingLevel, + description: 'else', + }) + } + + // else if의 자식 탐색 (nesting 유지) + walkIfChildren(node, nestingLevel, true) + return + } + + // 일반 structural 노드 + if (nestingLevel > 0 && NESTING_NODES.has(node.type)) { + increments.push({line, type: 'structural', value: 1, nestingLevel, description: getKindDescription(node)}) + increments.push({ + line, + type: 'nesting', + value: nestingLevel, + nestingLevel, + description: `${getKindDescription(node)} (nesting: ${nestingLevel})`, + }) + } else { + increments.push({line, type: 'structural', value: 1, nestingLevel, description: getKindDescription(node)}) + } + + // else 처리 + if (node.type === 'IfStatement' && node.alternate && node.alternate.type !== 'IfStatement') { + increments.push({ + line: node.alternate.loc.start.line, + type: 'structural', + value: 1, + nestingLevel, + description: 'else', + }) + } + + const nextNesting = INCREASES_NESTING_NODES.has(node.type) ? nestingLevel + 1 : nestingLevel + + if (node.type === 'IfStatement') { + walkIfChildren(node, nextNesting, false) + } else { + walkChildren(node, nextNesting) + } + } + + const walkIfChildren = (node, childNesting, isElseIfBranch) => { + // then 분기 + walk(node.consequent, childNesting) + + // else 분기 + if (node.alternate) { + if (node.alternate.type === 'IfStatement') { + // else if 체인: nesting을 올리지 않음 + walk(node.alternate, isElseIfBranch ? childNesting : childNesting - 1) + } else { + walk(node.alternate, childNesting) + } + } + + // 조건식 (논리 연산자 포함 가능) + walk(node.test, isElseIfBranch ? childNesting : childNesting - 1) + } + + /** + * 논리 연산자 시퀀스 처리. + * sonarjs 호환: && 만 계산. 같은 연산자 연속은 +1, 다른 연산자로 전환되면 새 +1. + */ + const handleLogicalExpression = (node, nestingLevel) => { + const sequences = flattenLogicalExpression(node) + + let lastOperator = null + for (const seq of sequences) { + if (seq.operator !== lastOperator) { + increments.push({ + line: seq.line, + type: 'logical-operator', + value: 1, + nestingLevel, + description: `logical: ${seq.operator}`, + }) + lastOperator = seq.operator + } + } + + // 논리식의 리프 노드들 탐색 + const leaves = collectLogicalLeaves(node) + for (const leaf of leaves) { + walk(leaf, nestingLevel) + } + } + + const flattenLogicalExpression = (node) => { + const result = [] + const inner = (n) => { + if (n.type === 'LogicalExpression' && n.operator === '&&') { + inner(n.left) + result.push({operator: n.operator, line: n.loc.start.line}) + } else { + // 왼쪽이 && 가 아니면 멈춤 — 현재 노드의 연산자만 추가 + } + } + inner(node.left) + result.push({operator: node.operator, line: node.loc.start.line}) + return result + } + + const collectLogicalLeaves = (node) => { + const leaves = [] + const inner = (n) => { + if (n.type === 'LogicalExpression' && n.operator === '&&') { + inner(n.left) + inner(n.right) + } else { + leaves.push(n) + } + } + inner(node) + return leaves + } + + if (bodyNode) { + if (bodyNode.type === 'BlockStatement') { + for (const stmt of bodyNode.body) { + walk(stmt, 0) + } + } else { + // arrow function with expression body + walk(bodyNode, 0) + } + } + + const score = increments.reduce((sum, inc) => sum + inc.value, 0) + return {score, increments, maxNestingDepth} +} + +// --------------------------------------------------------------------------- +// Breakdown (라인별 점수 상세) +// --------------------------------------------------------------------------- + +const getBreakdown = (increments) => { + if (increments.length === 0) return '' + + const byLine = new Map() + for (const inc of increments) { + if (!byLine.has(inc.line)) byLine.set(inc.line, []) + byLine.get(inc.line).push(inc) + } + + const parts = [] + for (const [line, incs] of [...byLine].sort((a, b) => a[0] - b[0])) { + const total = incs.reduce((s, i) => s + i.value, 0) + const nesting = incs.find((i) => i.type === 'nesting') + const mains = incs.filter((i) => i.type !== 'nesting') + const kind = mains.map((m) => toKorean(m.description)).join(', ') + + if (nesting) { + parts.push(`+${total} (L${line}) ${kind} (중첩 ${nesting.value}단계에서 +${nesting.value})`) + } else { + parts.push(`+${total} (L${line}) ${kind}`) + } + } + + return '\n 점수 상세:\n' + parts.map((p) => ` ${p}`).join('\n') +} + +// --------------------------------------------------------------------------- +// Suggestions (6종) +// --------------------------------------------------------------------------- + +const TERMINATING_TYPES = new Set(['ReturnStatement', 'ThrowStatement', 'ContinueStatement', 'BreakStatement']) + +const endsWithTerminating = (node) => { + if (!node) return false + if (TERMINATING_TYPES.has(node.type)) return true + if (node.type === 'BlockStatement' && node.body.length > 0) { + return TERMINATING_TYPES.has(node.body[node.body.length - 1].type) + } + return false +} + +const findMergeableNestedIfs = (bodyNode) => { + const results = [] + + const visit = (node) => { + if (!node || typeof node !== 'object') return + if (isFunctionNode(node)) return + + if ( + node.type === 'IfStatement' && + !node.alternate && + node.consequent?.type === 'BlockStatement' && + node.consequent.body.length === 1 && + node.consequent.body[0].type === 'IfStatement' && + !node.consequent.body[0].alternate + ) { + results.push(node.loc.start.line) + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue + const child = node[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item.type === 'string') visit(item) + } + } else if (child && typeof child === 'object' && typeof child.type === 'string') { + visit(child) + } + } + } + + if (bodyNode) visit(bodyNode) + return results +} + +const findInvertibleIfs = (bodyNode) => { + const results = [] + + const visit = (node) => { + if (!node || typeof node !== 'object') return + if (isFunctionNode(node)) return + + if (node.type === 'IfStatement' && node.alternate) { + if (endsWithTerminating(node.consequent) || endsWithTerminating(node.alternate)) { + const isElseIfNode = node.parent?.type === 'IfStatement' && node.parent.alternate === node + if (!isElseIfNode) { + results.push(node.loc.start.line) + } + } + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue + const child = node[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item.type === 'string') visit(item) + } + } else if (child && typeof child === 'object' && typeof child.type === 'string') { + visit(child) + } + } + } + + if (bodyNode) visit(bodyNode) + return results +} + +const getSuggestions = (increments, maxNestingDepth, score, lineCount, threshold, bodyNode) => { + const suggestions = [] + + // 1. guard-clause + if (increments.length >= 2 && increments[0]?.description === 'if') { + const afterFirst = increments.slice(1) + const nestedCount = afterFirst.filter((i) => i.nestingLevel >= 1).length + if (nestedCount > 0 && nestedCount >= afterFirst.length * 0.6) { + suggestions.push(`L${increments[0].line}의 초기 조건을 반전시키고 조기 반환(guard clause)하세요`) + } + } + + // 2. extract-function (deep nesting) + if (maxNestingDepth >= 3) { + const deepIncrements = increments.filter((i) => i.nestingLevel >= 3) + const deepLines = [...new Set(deepIncrements.map((i) => i.line))].sort((a, b) => a - b) + const lineRef = deepLines.length > 0 ? deepLines.map((l) => `L${l}`).join(', ') : '' + const prefix = lineRef ? `${lineRef}에서 ` : '' + suggestions.push(`${prefix}중첩 깊이 ${maxNestingDepth}단계. 이 블록을 별도 함수로 추출하세요`) + } + + // 3. extract-boolean + const logicalOps = increments.filter((i) => i.type === 'logical-operator') + if (logicalOps.length >= 2) { + const logicalLines = [...new Set(logicalOps.map((i) => i.line))].sort((a, b) => a - b) + const lineRef = logicalLines.map((l) => `L${l}`).join(', ') + suggestions.push(`${lineRef}에 논리 연산자 ${logicalOps.length}개. 복잡한 조건을 이름 있는 변수로 추출하세요`) + } + + // 4. split-function + if (lineCount >= 30 && score >= threshold) { + suggestions.push(`함수가 ${lineCount}줄이며 복잡도가 ${score}입니다. 더 작은 함수로 분리하세요`) + } + + // 5. merge-nested-if + const mergeableLines = findMergeableNestedIfs(bodyNode) + if (mergeableLines.length > 0) { + const lineRef = mergeableLines.map((l) => `L${l}`).join(', ') + suggestions.push(`${lineRef}의 중첩 if문을 && 조건으로 병합하여 중첩을 줄이세요`) + } + + // 6. invert-if-to-reduce-nesting + const invertibleLines = findInvertibleIfs(bodyNode) + if (invertibleLines.length > 0) { + const lineRef = invertibleLines.map((l) => `L${l}`).join(', ') + suggestions.push( + `${lineRef}의 if-else에서 한 분기가 return/throw로 끝납니다. 조건을 반전시키고 else를 제거하여 중첩을 줄이세요`, + ) + } + + return suggestions +} + +// --------------------------------------------------------------------------- +// Function name extraction +// --------------------------------------------------------------------------- + +const getFunctionName = (node) => { + // function foo() {} + if (node.id) return node.id.name + + const {parent} = node + if (!parent) return '' + + // const foo = () => {} / const foo = function() {} + if (parent.type === 'VariableDeclarator' && parent.id) { + return parent.id.name + } + + // { foo: () => {} } + if (parent.type === 'Property' && parent.key) { + return parent.key.name || parent.key.value || '' + } + + // class method: MethodDefinition + if (parent.type === 'MethodDefinition' && parent.key) { + const methodName = parent.key.name || parent.key.value || 'anonymous' + const classNode = parent.parent?.parent + const className = + classNode && (classNode.type === 'ClassDeclaration' || classNode.type === 'ClassExpression') && classNode.id + ? classNode.id.name + : null + return className ? `${className}.${methodName}` : methodName + } + + // class property: PropertyDefinition + if (parent.type === 'PropertyDefinition' && parent.key) { + const propName = parent.key.name || parent.key.value || 'anonymous' + const classNode = parent.parent?.parent + const className = + classNode && (classNode.type === 'ClassDeclaration' || classNode.type === 'ClassExpression') && classNode.id + ? classNode.id.name + : null + return className ? `${className}.${propName}` : propName + } + + return '' +} + +// --------------------------------------------------------------------------- +// Rule +// --------------------------------------------------------------------------- + +/** + * @type {import('eslint').Rule.RuleModule} + */ +export default { + meta: { + type: 'suggestion', + docs: { + description: '함수의 Cognitive Complexity가 임계값을 초과하면 리팩토링 제안을 포함하여 보고합니다.', + recommended: false, + }, + schema: [ + { + type: 'integer', + minimum: 0, + default: 15, + }, + { + type: 'object', + properties: { + suggestions: { + type: 'boolean', + default: false, + }, + }, + additionalProperties: false, + }, + ], + messages: { + complexFunction: + "'{{name}}'의 Cognitive Complexity가 {{score}}입니다 (허용: {{threshold}}).{{breakdown}}{{suggestions}}", + }, + }, + create(context) { + const threshold = context.options[0] ?? 15 + const showSuggestions = context.options[1]?.suggestions ?? false + + const checkFunction = (node) => { + const name = getFunctionName(node) + const body = node.body + const {score, increments, maxNestingDepth} = calculateComplexity(body) + + if (score > threshold) { + const startLine = node.loc.start.line + const endLine = node.loc.end.line + const lineCount = endLine - startLine + 1 + + const breakdownText = getBreakdown(increments) + let suggestionsText = '' + if (showSuggestions) { + const suggestions = getSuggestions(increments, maxNestingDepth, score, lineCount, threshold, body) + suggestionsText = + suggestions.length > 0 ? '\n 제안:\n' + suggestions.map((s) => ` - ${s}`).join('\n') : '' + } + + context.report({ + node, + messageId: 'complexFunction', + data: { + name, + score: String(score), + threshold: String(threshold), + breakdown: breakdownText, + suggestions: suggestionsText, + }, + }) + } + } + + return { + FunctionDeclaration: checkFunction, + FunctionExpression: checkFunction, + ArrowFunctionExpression: checkFunction, + } + }, +} diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js new file mode 100644 index 0000000..799d343 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js @@ -0,0 +1,425 @@ +import {RuleTester} from 'eslint' +import {describe, it} from 'vitest' + +import rule from './cognitive-complexity.js' + +describe('cognitive-complexity', () => { + const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }) + + it('allows functions under the threshold', () => { + tester.run('cognitive-complexity', rule, { + valid: [ + { + code: 'function foo() {}', + options: [15], + }, + { + code: 'const foo = (x) => x + 1;', + options: [15], + }, + { + // simple if → score 1, threshold 15 + code: `function foo(x) { + if (x > 0) { return x; } + }`, + options: [15], + }, + { + // if-else → score 2, threshold 15 + code: `function foo(x) { + if (x > 0) { return x; } + else { return -x; } + }`, + options: [15], + }, + ], + invalid: [], + }) + }) + + it('reports simple if with low threshold', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if → +1, threshold 0 + code: `function foo(x) { + if (x > 0) { return x; } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports if-else', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, else +1 → score 2 + code: `function foo(x) { + if (x > 0) { return x; } + else { return -x; } + }`, + options: [1], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports if-else if-else', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, else if +1, else +1 → score 3 + code: `function foo(x) { + if (x > 0) { return 1; } + else if (x < 0) { return -1; } + else { return 0; } + }`, + options: [2], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports nested if', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, nested if +1(structural) +1(nesting) → score 3 + code: `function foo(a, b) { + if (a) { + if (b) { return true; } + } + }`, + options: [2], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports deeply nested code with suggestions', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, for +1+1, if +1+2, if +1+3 → score 10 + code: `function foo(a, b, c) { + if (a) { + for (let i = 0; i < 10; i++) { + if (b) { + if (c) { return true; } + } + } + } + }`, + options: [5], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports logical operator &&', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, && +1 → score 2 + code: `function foo(a, b) { + if (a && b) { return true; } + }`, + options: [1], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('same && sequence counts as one', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if +1, && +1 (same sequence) → score 2 + code: `function foo(a, b, c) { + if (a && b && c) { return true; } + }`, + options: [1], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports for loop', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(arr) { + for (let i = 0; i < arr.length; i++) { console.log(arr[i]); } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports while loop', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(x) { + while (x > 0) { x--; } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports do-while loop', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(x) { + do { x--; } while (x > 0); + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports switch statement', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(x) { + switch (x) { + case 'a': return 1; + case 'b': return 2; + default: return 0; + } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports catch clause', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo() { + try { doSomething(); } + catch (e) { handleError(e); } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('reports ternary', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(x) { + return x > 0 ? x : -x; + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('handles arrow function names', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `const foo = (x) => { + if (x) { return 1; } + }`, + options: [0], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('nested function does not contribute to parent complexity', () => { + tester.run('cognitive-complexity', rule, { + valid: [ + { + // parent: if +1 → score 1. nested callback is separate + code: `function foo(arr) { + if (arr.length > 0) { + arr.forEach((item) => { + if (item > 0) { console.log(item); } + }); + } + }`, + options: [1], + }, + ], + invalid: [], + }) + }) + + it('reports sumOfPrimes example', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // for +1, for +1+1, if +1+2, continue → no label, not counted → score 6 + code: `function sumOfPrimes(max) { + let total = 0; + for (let i = 2; i <= max; i++) { + for (let j = 2; j < i; j++) { + if (i % j === 0) { continue; } + } + total += i; + } + return total; + }`, + options: [5], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('uses default threshold of 15', () => { + tester.run('cognitive-complexity', rule, { + valid: [ + { + // score 1, default threshold 15 + code: `function foo(x) { + if (x) { return 1; } + }`, + }, + ], + invalid: [], + }) + }) + + it('does not show suggestions by default', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // deeply nested → triggers extract-function suggestion + code: `function foo(a, b, c) { + if (a) { + for (let i = 0; i < 10; i++) { + if (b) { + if (c) { return true; } + } + } + } + }`, + options: [5], + errors: [ + { + messageId: 'complexFunction', + // message should NOT contain '제안:' + }, + ], + }, + ], + }) + }) + + it('shows suggestions when suggestions option is true', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(a, b, c) { + if (a) { + for (let i = 0; i < 10; i++) { + if (b) { + if (c) { return true; } + } + } + } + }`, + options: [5, {suggestions: true}], + errors: [ + { + messageId: 'complexFunction', + // message should contain '제안:' + }, + ], + }, + ], + }) + }) + + it('suggests merge-nested-if', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if(a) { if(b) { ... } } → if(a && b)로 병합 가능 + code: `function foo(a, b) { + if (a) { + if (b) { doSomething(); } + } + }`, + options: [0, {suggestions: true}], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) + + it('suggests invert-if-to-reduce-nesting', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + // if-else에서 else가 return으로 끝남 → 조건 반전 가능 + code: `function foo(x) { + if (x > 0) { + doA(); + doB(); + } else { + return; + } + }`, + options: [0, {suggestions: true}], + errors: [{messageId: 'complexFunction'}], + }, + ], + }) + }) +}) From 6a1d8381bc78bfa3d8233f6bfc6f7d29f9376dcc Mon Sep 17 00:00:00 2001 From: hyunseok-kim-f Date: Fri, 6 Mar 2026 20:44:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#153]=20=F0=9F=93=9D=20docs:=20cognitive-c?= =?UTF-8?q?omplexity=20=EB=A3=B0=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md 규칙 테이블에 cognitive-complexity 항목 추가 - docs/cognitive-complexity.md 사용법 및 옵션 설명 문서 작성 Co-Authored-By: Claude Opus 4.6 --- packages/eslint-plugin/README.md | 1 + .../docs/cognitive-complexity.md | 146 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/eslint-plugin/docs/cognitive-complexity.md diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index d57a7dc..a4b34a0 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -45,3 +45,4 @@ export default [ | [svg-unique-id](docs/svg-unique-id.md) | 주어진 경로의 SVG 컴포넌트들에 고유한 id를 부여하는 HOC를 추가합니다. | 🔧 | | [import-server-only](docs/import-server-only.md) | 주어진 경로의 파일에 server-only 패키지를 포함하도록 강제합니다. | 🔧 | | [peer-deps-in-dev-deps](docs/peer-deps-in-dev-deps.md) | `package.json`에서 동작하는 규칙으로, `peerDependencies` 에 있는 패키지가 `devDependencies` 에 선언되어 있지 않다면 에러를 발생시킵니다. | | +| [cognitive-complexity](docs/cognitive-complexity.md) | 함수의 Cognitive Complexity가 임계값을 초과하면 라인별 점수 상세와 리팩토링 제안을 포함하여 보고합니다. | | diff --git a/packages/eslint-plugin/docs/cognitive-complexity.md b/packages/eslint-plugin/docs/cognitive-complexity.md new file mode 100644 index 0000000..b0928ad --- /dev/null +++ b/packages/eslint-plugin/docs/cognitive-complexity.md @@ -0,0 +1,146 @@ +# `@naverpay/cognitive-complexity` + +> **이 규칙은** 함수의 [Cognitive Complexity](https://www.sonarsource.com/docs/CognitiveComplexity.pdf)가 임계값을 초과하면 **라인별 점수 상세**와 함께 보고합니다. + +## 설명 + +기존 `eslint-plugin-sonarjs`의 `cognitive-complexity` 규칙은 총점만 알려줄 뿐, **왜 높은지** 알 수 없습니다. + +이 규칙은 SonarSource Cognitive Complexity 스펙 기반으로 점수를 계산하고, **어떤 라인의 어떤 구문이 몇 점을 기여했는지** 상세하게 안내합니다. 옵션을 통해 6종의 리팩토링 제안도 함께 받을 수 있습니다. + +### This will be reported + +```js +// threshold 5 초과 → 에러 +function processOrder(order, user) { + if (order.items.length > 0) { // +1 if문 + for (const item of order.items) { // +2 for-of문 (중첩 1단계에서 +1) + if (item.quantity > 0 && item.price) { // +4 if문 (중첩 2단계에서 +2), && 연산자 + if (user.isVip) { // +4 if문 (중첩 3단계에서 +3) + applyDiscount(item) + } + } + } + } +} +// Cognitive Complexity: 11 (허용: 5) +``` + +### This will not be reported + +```js +// threshold 15 이하 → 통과 +function greet(name) { + if (!name) { + return 'Hello, stranger!' + } + return `Hello, ${name}!` +} +// Cognitive Complexity: 1 +``` + +## 출력 예시 + +### 기본 (점수 상세) + +``` +'processOrder'의 Cognitive Complexity가 11입니다 (허용: 5). + 점수 상세: + +1 (L3) if문 + +2 (L4) for-of문 (중첩 1단계에서 +1) + +4 (L5) if문, && 연산자 (중첩 2단계에서 +2) + +4 (L6) if문 (중첩 3단계에서 +3) +``` + +### `suggestions: true` 옵션 사용 시 + +``` +'processOrder'의 Cognitive Complexity가 11입니다 (허용: 5). + 점수 상세: + +1 (L3) if문 + +2 (L4) for-of문 (중첩 1단계에서 +1) + +4 (L5) if문, && 연산자 (중첩 2단계에서 +2) + +4 (L6) if문 (중첩 3단계에서 +3) + 제안: + - L3의 초기 조건을 반전시키고 조기 반환(guard clause)하세요 + - L6에서 중첩 깊이 3단계. 이 블록을 별도 함수로 추출하세요 +``` + +## 점수 계산 방식 + +| 항목 | 증분 | 예시 | +| :--- | :--- | :--- | +| 구조적 구문 | +1 | `if`, `else if`, `else`, `for`, `while`, `do-while`, `switch`, `catch`, 삼항 연산자 | +| 중첩 | +중첩 단계 | 위 구문이 다른 구문 안에 있을 때 추가 | +| 논리 연산자 `&&` | +1 (시퀀스당) | `a && b && c` → +1, `a && b \|\| c` → && +1 | +| 라벨 break/continue | +1 | `break outerLoop` | +| 중첩 함수 | 별도 계산 | 부모 함수의 복잡도에 기여하지 않음 | + +## 제안 종류 (6종) + +`suggestions: true` 옵션을 사용하면 다음 제안이 조건에 따라 포함됩니다: + +| 제안 | 조건 | 설명 | +| :--- | :--- | :--- | +| guard-clause | 첫 if 이후 60%+ 증분이 중첩 | 초기 조건을 반전시키고 조기 반환하세요 | +| extract-function | 중첩 깊이 >= 3 | 깊은 블록을 별도 함수로 추출하세요 | +| extract-boolean | 논리 연산자 >= 2개 | 복잡한 조건을 이름 있는 변수로 추출하세요 | +| split-function | 30줄 이상 + threshold 초과 | 더 작은 함수로 분리하세요 | +| merge-nested-if | `if(a) { if(b) {...} }` (양쪽 else 없음) | 중첩 if문을 `&&` 조건으로 병합하세요 | +| invert-if | if-else에서 한 분기가 return/throw로 끝남 | 조건을 반전시키고 else를 제거하세요 | + +## 옵션 + +### 첫 번째 인자: `threshold` (integer, 기본값: 15) + +허용할 최대 Cognitive Complexity 점수입니다. + +### 두 번째 인자: `options` (object, 선택) + +| 속성 | 타입 | 기본값 | 설명 | +| :--- | :--- | :--- | :--- | +| `suggestions` | `boolean` | `false` | 리팩토링 제안을 에러 메시지에 포함할지 여부 | + +## 설정 + +```js +// eslint.config.js +import naverpayPlugin from '@naverpay/eslint-plugin' + +export default [ + { + plugins: {'@naverpay': naverpayPlugin}, + rules: { + // 기본: threshold 15, 점수 상세만 표시 + '@naverpay/cognitive-complexity': ['error', 15], + }, + }, +] +``` + +### 제안 포함 + +```js +export default [ + { + plugins: {'@naverpay': naverpayPlugin}, + rules: { + '@naverpay/cognitive-complexity': ['error', 15, {suggestions: true}], + }, + }, +] +``` + +### threshold 변경 + +```js +export default [ + { + plugins: {'@naverpay': naverpayPlugin}, + rules: { + // 더 엄격하게 10으로 설정 + 제안 포함 + '@naverpay/cognitive-complexity': ['error', 10, {suggestions: true}], + }, + }, +] +``` From 091cd11fd86bf20d28ee26d9a84fe8414695ad50 Mon Sep 17 00:00:00 2001 From: hyunseok-kim-f Date: Wed, 11 Mar 2026 21:02:31 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#153]=20=E2=9C=A8=20feat:=20cognitive-comp?= =?UTF-8?q?lexity=20=EB=A3=B0=EC=97=90=20breakdown=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 점수 상세 표시를 breakdown 옵션으로 제어할 수 있도록 변경 (기본값: false) --- .../docs/cognitive-complexity.md | 15 +++++++ .../lib/rules/cognitive-complexity.js | 7 ++- .../lib/rules/cognitive-complexity.test.js | 44 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/cognitive-complexity.md b/packages/eslint-plugin/docs/cognitive-complexity.md index b0928ad..45b07ed 100644 --- a/packages/eslint-plugin/docs/cognitive-complexity.md +++ b/packages/eslint-plugin/docs/cognitive-complexity.md @@ -99,6 +99,7 @@ function greet(name) { | 속성 | 타입 | 기본값 | 설명 | | :--- | :--- | :--- | :--- | +| `breakdown` | `boolean` | `false` | 라인별 점수 상세를 에러 메시지에 포함할지 여부 | | `suggestions` | `boolean` | `false` | 리팩토링 제안을 에러 메시지에 포함할지 여부 | ## 설정 @@ -131,6 +132,20 @@ export default [ ] ``` +### 점수 상세 비활성화 + +```js +export default [ + { + plugins: {'@naverpay': naverpayPlugin}, + rules: { + // 점수 상세 없이 총점만 표시 + '@naverpay/cognitive-complexity': ['error', 15, {breakdown: false}], + }, + }, +] +``` + ### threshold 변경 ```js diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.js index 09dd650..05977c0 100644 --- a/packages/eslint-plugin/lib/rules/cognitive-complexity.js +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.js @@ -536,6 +536,10 @@ export default { { type: 'object', properties: { + breakdown: { + type: 'boolean', + default: false, + }, suggestions: { type: 'boolean', default: false, @@ -551,6 +555,7 @@ export default { }, create(context) { const threshold = context.options[0] ?? 15 + const showBreakdown = context.options[1]?.breakdown ?? false const showSuggestions = context.options[1]?.suggestions ?? false const checkFunction = (node) => { @@ -563,7 +568,7 @@ export default { const endLine = node.loc.end.line const lineCount = endLine - startLine + 1 - const breakdownText = getBreakdown(increments) + const breakdownText = showBreakdown ? getBreakdown(increments) : '' let suggestionsText = '' if (showSuggestions) { const suggestions = getSuggestions(increments, maxNestingDepth, score, lineCount, threshold, body) diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js index 799d343..2b4b2c1 100644 --- a/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.test.js @@ -384,6 +384,50 @@ describe('cognitive-complexity', () => { }) }) + it('hides breakdown by default', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(a, b) { + if (a) { + if (b) { return true; } + } + }`, + options: [0], + errors: [ + { + messageId: 'complexFunction', + // message should NOT contain '점수 상세:' + }, + ], + }, + ], + }) + }) + + it('shows breakdown when breakdown option is true', () => { + tester.run('cognitive-complexity', rule, { + valid: [], + invalid: [ + { + code: `function foo(a, b) { + if (a) { + if (b) { return true; } + } + }`, + options: [0, {breakdown: true}], + errors: [ + { + messageId: 'complexFunction', + // message should contain '점수 상세:' + }, + ], + }, + ], + }) + }) + it('suggests merge-nested-if', () => { tester.run('cognitive-complexity', rule, { valid: [], From 4046028212b581cbd00629cb84d85465a6d850d5 Mon Sep 17 00:00:00 2001 From: keemhyunseok Date: Wed, 11 Mar 2026 21:10:45 +0900 Subject: [PATCH 4/6] Create cute-actors-invent.md --- .changeset/cute-actors-invent.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cute-actors-invent.md diff --git a/.changeset/cute-actors-invent.md b/.changeset/cute-actors-invent.md new file mode 100644 index 0000000..107b83a --- /dev/null +++ b/.changeset/cute-actors-invent.md @@ -0,0 +1,7 @@ +--- +"@naverpay/eslint-plugin": patch +--- + +cognitive-complexity eslint plugin을 추가합니다 + +PR: [cognitive-complexity eslint plugin을 추가합니다](https://github.com/NaverPayDev/code-style/pull/154) From 91044d1a54b30b5d3eeb241c89d93bf8d096f15d Mon Sep 17 00:00:00 2001 From: hyunseok-kim-f Date: Thu, 12 Mar 2026 11:22:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#153]=20=E2=9C=A8=20refactor:=20breakdown?= =?UTF-8?q?=20=EC=B6=9C=EB=A0=A5=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toKorean 변환 함수 제거 및 description 값을 그대로 노출하도록 수정 점수 상세 라벨(점수 상세 → Score breakdown), 중첩 표기(중첩 N단계 → nesting depth N) 영어 통일 --- .../docs/cognitive-complexity.md | 28 ++++++------- .../lib/rules/cognitive-complexity.js | 40 ++----------------- 2 files changed, 17 insertions(+), 51 deletions(-) diff --git a/packages/eslint-plugin/docs/cognitive-complexity.md b/packages/eslint-plugin/docs/cognitive-complexity.md index 45b07ed..51bed84 100644 --- a/packages/eslint-plugin/docs/cognitive-complexity.md +++ b/packages/eslint-plugin/docs/cognitive-complexity.md @@ -13,10 +13,10 @@ ```js // threshold 5 초과 → 에러 function processOrder(order, user) { - if (order.items.length > 0) { // +1 if문 - for (const item of order.items) { // +2 for-of문 (중첩 1단계에서 +1) - if (item.quantity > 0 && item.price) { // +4 if문 (중첩 2단계에서 +2), && 연산자 - if (user.isVip) { // +4 if문 (중첩 3단계에서 +3) + if (order.items.length > 0) { // +1 if + for (const item of order.items) { // +2 for-of (nesting depth 1, +1) + if (item.quantity > 0 && item.price) { // +4 if, logical: && (nesting depth 2, +2) + if (user.isVip) { // +4 if (nesting depth 3, +3) applyDiscount(item) } } @@ -45,22 +45,22 @@ function greet(name) { ``` 'processOrder'의 Cognitive Complexity가 11입니다 (허용: 5). - 점수 상세: - +1 (L3) if문 - +2 (L4) for-of문 (중첩 1단계에서 +1) - +4 (L5) if문, && 연산자 (중첩 2단계에서 +2) - +4 (L6) if문 (중첩 3단계에서 +3) + Score breakdown: + +1 (L3) if + +2 (L4) for-of (nesting depth 1, +1) + +4 (L5) if, logical: && (nesting depth 2, +2) + +4 (L6) if (nesting depth 3, +3) ``` ### `suggestions: true` 옵션 사용 시 ``` 'processOrder'의 Cognitive Complexity가 11입니다 (허용: 5). - 점수 상세: - +1 (L3) if문 - +2 (L4) for-of문 (중첩 1단계에서 +1) - +4 (L5) if문, && 연산자 (중첩 2단계에서 +2) - +4 (L6) if문 (중첩 3단계에서 +3) + Score breakdown: + +1 (L3) if + +2 (L4) for-of (nesting depth 1, +1) + +4 (L5) if, logical: && (nesting depth 2, +2) + +4 (L6) if (nesting depth 3, +3) 제안: - L3의 초기 조건을 반전시키고 조기 반환(guard clause)하세요 - L6에서 중첩 깊이 3단계. 이 블록을 별도 함수로 추출하세요 diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.js index 05977c0..b64c83f 100644 --- a/packages/eslint-plugin/lib/rules/cognitive-complexity.js +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.js @@ -57,40 +57,6 @@ const getKindDescription = (node) => { } } -const toKorean = (description) => { - switch (description) { - case 'if': - return 'if문' - case 'else if': - return 'else if문' - case 'else': - return 'else문' - case 'for': - return 'for문' - case 'for-in': - return 'for-in문' - case 'for-of': - return 'for-of문' - case 'while': - return 'while문' - case 'do-while': - return 'do-while문' - case 'switch': - return 'switch문' - case 'catch': - return 'catch문' - case 'ternary': - return '삼항 연산자' - case 'break to label': - return '라벨 break문' - case 'continue to label': - return '라벨 continue문' - default: - if (description.startsWith('logical: ')) return description.slice(9) + ' 연산자' - return description - } -} - // --------------------------------------------------------------------------- // Complexity calculation (ESTree walker) // --------------------------------------------------------------------------- @@ -319,16 +285,16 @@ const getBreakdown = (increments) => { const total = incs.reduce((s, i) => s + i.value, 0) const nesting = incs.find((i) => i.type === 'nesting') const mains = incs.filter((i) => i.type !== 'nesting') - const kind = mains.map((m) => toKorean(m.description)).join(', ') + const kind = mains.map((m) => m.description).join(', ') if (nesting) { - parts.push(`+${total} (L${line}) ${kind} (중첩 ${nesting.value}단계에서 +${nesting.value})`) + parts.push(`+${total} (L${line}) ${kind} (nesting depth ${nesting.value}, +${nesting.value})`) } else { parts.push(`+${total} (L${line}) ${kind}`) } } - return '\n 점수 상세:\n' + parts.map((p) => ` ${p}`).join('\n') + return '\n Score breakdown:\n' + parts.map((p) => ` ${p}`).join('\n') } // --------------------------------------------------------------------------- From cb37de60508b947da16c7c37bceddf0031c34484 Mon Sep 17 00:00:00 2001 From: hyunseok-kim-f Date: Thu, 12 Mar 2026 11:42:18 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#153]=20=F0=9F=90=9B=20fix:=20else=20if=20?= =?UTF-8?q?body=EC=9D=98=20nesting=20=EA=B3=84=EC=82=B0=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sonarjs 스펙상 else if 자체는 nesting 증가 없지만, else if body 내부 구문은 nesting+1 레벨에서 평가해야 함 --- .../eslint-plugin/lib/rules/cognitive-complexity.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/eslint-plugin/lib/rules/cognitive-complexity.js b/packages/eslint-plugin/lib/rules/cognitive-complexity.js index b64c83f..7a8e1db 100644 --- a/packages/eslint-plugin/lib/rules/cognitive-complexity.js +++ b/packages/eslint-plugin/lib/rules/cognitive-complexity.js @@ -138,8 +138,8 @@ const calculateComplexity = (bodyNode) => { }) } - // else if의 자식 탐색 (nesting 유지) - walkIfChildren(node, nestingLevel, true) + // else if의 자식 탐색 (else if 자체는 nesting 증가 없지만, body는 한 단계 깊어짐) + walkIfChildren(node, nestingLevel + 1) return } @@ -177,22 +177,22 @@ const calculateComplexity = (bodyNode) => { } } - const walkIfChildren = (node, childNesting, isElseIfBranch) => { + const walkIfChildren = (node, childNesting) => { // then 분기 walk(node.consequent, childNesting) // else 분기 if (node.alternate) { if (node.alternate.type === 'IfStatement') { - // else if 체인: nesting을 올리지 않음 - walk(node.alternate, isElseIfBranch ? childNesting : childNesting - 1) + // else if 체인: else if 자체의 nesting은 부모와 동일한 레벨 + walk(node.alternate, childNesting - 1) } else { walk(node.alternate, childNesting) } } // 조건식 (논리 연산자 포함 가능) - walk(node.test, isElseIfBranch ? childNesting : childNesting - 1) + walk(node.test, childNesting - 1) } /**