File size: 6,374 Bytes
eca5751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""
Code Smells Detector - Phát hiện code smells trong Python file.
Author: Hieu Louis (2026)

Phát hiện các smells:
- long_function       : quá nhiều statements/lines
- too_many_params     : > 5 parameters
- deep_nesting        : nesting > 4 levels
- long_class          : class có quá nhiều methods
- duplicate_string_literal : string literal xuất hiện ≥ 3 lần (len ≥ 5)

Dùng `ast` để walk tree. Read-only (SAFE).
"""
from __future__ import annotations

import ast
import json
from collections import Counter
from typing import Any, Dict, List, Optional

from .base import Tool, ToolResult, ToolContext, ToolCategory, ToolSafety


# Ngưỡng smell // smell thresholds (tunable)
LONG_FUNCTION_STMTS = 50
LONG_FUNCTION_LINES = 50
TOO_MANY_PARAMS = 5
DEEP_NESTING = 4
LONG_CLASS_METHODS = 20
DUP_LIT_MIN_COUNT = 3
DUP_LIT_MIN_LEN = 5
DUP_LIT_MAX_REPORT = 50


class _SmellVisitor(ast.NodeVisitor):
    """Visitor quét AST để phát hiện code smells."""

    def __init__(self) -> None:
        self.smells: List[Dict[str, Any]] = []
        self._str_literals: List[str] = []

    def _record(self, kind: str, name: str, line: int, detail: Dict[str, Any]) -> None:
        self.smells.append({"kind": kind, "name": name, "line": line, **detail})

    def _max_nesting(self, node: ast.AST, depth: int = 0) -> int:
        """Tính độ sâu nesting tối đa trong block."""
        max_d = depth
        for child in ast.iter_child_nodes(node):
            if isinstance(child, (ast.If, ast.For, ast.While, ast.With, ast.Try, ast.ExceptHandler)):
                d = self._max_nesting(child, depth + 1)
            else:
                d = self._max_nesting(child, depth)
            if d > max_d:
                max_d = d
        return max_d

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._check_function(node)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._check_function(node)
        self.generic_visit(node)

    def _check_function(self, node: ast.FunctionDef) -> None:
        name = node.name
        line = node.lineno
        end_line = getattr(node, "end_lineno", line)
        # Statement count (approximate)
        n_stmts = sum(1 for _ in ast.walk(node) if isinstance(_, ast.stmt))
        n_lines = max(1, end_line - line + 1)
        if n_stmts > LONG_FUNCTION_STMTS or n_lines > LONG_FUNCTION_LINES:
            self._record("long_function", name, line, {"statements": n_stmts, "lines": n_lines})
        # Parameters count
        n_args = (
            len(node.args.args)
            + len(node.args.kwonlyargs)
            + len(node.args.posonlyargs)
        )
        if node.args.vararg:
            n_args += 1
        if node.args.kwarg:
            n_args += 1
        if n_args > TOO_MANY_PARAMS:
            self._record("too_many_params", name, line, {"params": n_args})
        # Nesting depth
        nesting = self._max_nesting(node)
        if nesting > DEEP_NESTING:
            self._record("deep_nesting", name, line, {"depth": nesting})

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        n_methods = sum(
            1 for m in node.body
            if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))
        )
        if n_methods > LONG_CLASS_METHODS:
            self._record("long_class", node.name, node.lineno, {"methods": n_methods})
        self.generic_visit(node)

    def visit_Constant(self, node: ast.Constant) -> None:
        # ast.Str deprecated in 3.8+; use ast.Constant
        if isinstance(node.value, str) and len(node.value) >= DUP_LIT_MIN_LEN:
            self._str_literals.append(node.value)
        self.generic_visit(node)


class CodeSmellsTool(Tool):
    """Phát hiện code smells (long function, too many params, deep nesting, ...)."""

    category = ToolCategory.CODE
    safety = ToolSafety.SAFE  # read-only analysis

    @property
    def name(self) -> str:
        return "code_smells"

    @property
    def description(self) -> str:
        return (
            "Phát hiện code smells trong Python file: long function, too many params, "
            "deep nesting, long class, duplicate string literals."
        )

    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File Python (.py) để phân tích"},
            },
            "required": ["path"],
        }

    def validate_args(self, args: Dict[str, Any]) -> Optional[str]:
        if not args.get("path"):
            return "Missing required arg: path"
        return None

    def execute(self, args: Dict[str, Any], context: ToolContext) -> ToolResult:
        path: str = args["path"]
        try:
            with open(path, "r", encoding="utf-8") as f:
                source = f.read()
        except Exception as e:
            return ToolResult(success=False, error=f"Không đọc được file: {e}", return_code=1)

        try:
            tree = ast.parse(source)
        except SyntaxError as e:
            return ToolResult(
                success=False,
                error=f"SyntaxError line {e.lineno}: {e.msg}",
                return_code=1,
            )

        visitor = _SmellVisitor()
        visitor.visit(tree)

        # Duplicate string literals analysis
        dup_lits = [
            {"literal": lit, "count": cnt}
            for lit, cnt in Counter(visitor._str_literals).most_common()
            if cnt >= DUP_LIT_MIN_COUNT
        ][:DUP_LIT_MAX_REPORT]
        for d in dup_lits:
            visitor.smells.append({
                "kind": "duplicate_string_literal",
                "name": "<literal>",
                "line": 0,
                **d,
            })

        by_kind: Dict[str, int] = {}
        for s in visitor.smells:
            by_kind[s["kind"]] = by_kind.get(s["kind"], 0) + 1

        return ToolResult(
            success=True,
            output=json.dumps(
                {"smells": visitor.smells, "summary": by_kind},
                indent=2, ensure_ascii=False,
            ),
            metadata={
                "path": path,
                "smell_count": len(visitor.smells),
                "by_kind": by_kind,
            },
        )