Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/workshop_mcp/complexity_analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Complexity analysis tools for measuring Python code complexity metrics."""

__version__ = "0.1.0"

from .calculator import CognitiveCalculator, CyclomaticCalculator
from .metrics import ClassMetrics, FileMetrics, FunctionMetrics, analyze_complexity
from .patterns import ComplexityCategory

__all__ = [
"CyclomaticCalculator",
"CognitiveCalculator",
"FunctionMetrics",
"ClassMetrics",
"FileMetrics",
"ComplexityCategory",
"analyze_complexity",
]
Comment on lines +1 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Module not under tools/ 📎 Requirement gap ✓ Correctness

• The new complexity analyzer code is added under src/workshop_mcp/complexity_analysis/ instead of
  the required src/workshop_mcp/tools/complexity_analysis/ package path.
• This breaks the expected project directory structure for tools and can cause import/discovery
  inconsistencies for MCP tool modules.
Agent Prompt
## Issue description
The complexity analyzer module is not located in the required tool package path `src/workshop_mcp/tools/complexity_analysis/`.

## Issue Context
Compliance requires tool implementations to exist under the `src/workshop_mcp/tools/` directory structure. The current module lives at `src/workshop_mcp/complexity_analysis/`, which does not meet that requirement.

## Fix Focus Areas
- src/workshop_mcp/complexity_analysis/__init__.py[1-17]
- src/workshop_mcp/complexity_analysis/calculator.py[1-120]
- src/workshop_mcp/complexity_analysis/metrics.py[1-333]
- src/workshop_mcp/complexity_analysis/patterns.py[1-87]
- src/workshop_mcp/server.py[13-18]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

120 changes: 120 additions & 0 deletions src/workshop_mcp/complexity_analysis/calculator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Cyclomatic and cognitive complexity calculators using Astroid."""

import astroid


class CyclomaticCalculator:
"""Calculates cyclomatic complexity for Python functions.

Cyclomatic complexity counts the number of linearly independent paths
through a function. Higher values indicate more complex branching logic.
"""

def calculate(self, node: astroid.FunctionDef | astroid.AsyncFunctionDef) -> int:
"""Calculate cyclomatic complexity for a function node.

Args:
node: An Astroid FunctionDef or AsyncFunctionDef node.

Returns:
Cyclomatic complexity score (minimum 1).
"""
complexity = 1 # Base complexity
complexity += self._count_branches(node)
return complexity

def _count_branches(self, node: astroid.NodeNG) -> int:
"""Recursively count branching constructs."""
count = 0
for _child in node.nodes_of_class(
(
astroid.If,
astroid.For,
astroid.While,
astroid.ExceptHandler,
astroid.With,
astroid.Assert,
astroid.IfExp,
astroid.Comprehension,
)
):
count += 1

# Count boolean operators in conditions
for bool_op in node.nodes_of_class(astroid.BoolOp):
# Each 'and'/'or' adds a new path
count += len(bool_op.values) - 1

return count


class CognitiveCalculator:
"""Calculates cognitive complexity (Sonar's metric) for Python functions.

Cognitive complexity measures how difficult code is to understand,
applying nesting penalties for structures inside other structures.
"""

def calculate(self, node: astroid.FunctionDef | astroid.AsyncFunctionDef) -> int:
"""Calculate cognitive complexity for a function node.

Args:
node: An Astroid FunctionDef or AsyncFunctionDef node.

Returns:
Cognitive complexity score (minimum 0).
"""
return self._walk(node, nesting=0, func_name=node.name)

def _walk(self, node: astroid.NodeNG, nesting: int, func_name: str) -> int:
"""Recursively walk the AST accumulating cognitive complexity."""
total = 0

for child in node.get_children():
if isinstance(child, (astroid.FunctionDef, astroid.AsyncFunctionDef)):
# Nested function definitions increase nesting
total += self._walk(child, nesting + 1, func_name)
continue

# Increment for breaks in linear flow + nesting penalty
if isinstance(child, astroid.If):
total += 1 + nesting # +1 for if + nesting penalty
total += self._walk(child, nesting + 1, func_name)
continue
elif isinstance(child, (astroid.For, astroid.While)):
total += 1 + nesting
total += self._walk(child, nesting + 1, func_name)
continue
elif isinstance(child, astroid.ExceptHandler):
total += 1 + nesting
total += self._walk(child, nesting + 1, func_name)
continue
elif isinstance(child, astroid.With):
total += 1 + nesting
total += self._walk(child, nesting + 1, func_name)
continue
elif isinstance(child, astroid.IfExp):
total += 1 + nesting
total += self._walk(child, nesting, func_name)
continue

# Boolean operators: +1 for each sequence
if isinstance(child, astroid.BoolOp):
total += 1

# Recursion: +1 when function calls itself
if isinstance(child, astroid.Call):
call_name = self._get_call_name(child)
if call_name == func_name:
total += 1

total += self._walk(child, nesting, func_name)

return total

@staticmethod
def _get_call_name(node: astroid.Call) -> str | None:
"""Get the simple name of a function call."""
if isinstance(node.func, astroid.Name):
return node.func.name
return None
Loading