@paulduvall/claude-dev-toolkit 0.0.1-alpha.19 → 0.0.1-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/.smellrc.example.json +19 -0
- package/hooks/check-commit-signing.py +127 -0
- package/hooks/check-complexity.py +38 -0
- package/hooks/check-security.py +37 -0
- package/hooks/claude-wrapper.sh +29 -0
- package/hooks/config.py +110 -0
- package/hooks/security_bandit.py +177 -0
- package/hooks/security_checks.py +97 -0
- package/hooks/security_secrets.py +81 -0
- package/hooks/security_trojan.py +61 -0
- package/hooks/settings.example.json +52 -0
- package/hooks/smell_checks.py +238 -0
- package/hooks/smell_javascript.py +231 -0
- package/hooks/smell_python.py +110 -0
- package/hooks/smell_ruff.py +70 -0
- package/hooks/smell_types.py +72 -0
- package/hooks/suppression.py +82 -0
- package/hooks/tab-color.sh +70 -0
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Hardcoded secrets detection via regex patterns.
|
|
2
|
+
|
|
3
|
+
Scans source lines for common secret patterns: API keys, tokens,
|
|
4
|
+
private key headers, and credential URLs. Skips known false positives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from smell_types import FIXES, Smell
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Secret patterns -- each is (compiled_regex, description)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
18
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key"),
|
|
19
|
+
(re.compile(r"ghp_[0-9a-zA-Z]{36}"), "GitHub personal access token"),
|
|
20
|
+
(re.compile(r"gho_[0-9a-zA-Z]{36}"), "GitHub OAuth token"),
|
|
21
|
+
(re.compile(r"xoxb-[0-9]{10,13}-[0-9a-zA-Z-]+"), "Slack bot token"),
|
|
22
|
+
(re.compile(r"xoxp-[0-9]{10,13}-[0-9a-zA-Z-]+"), "Slack user token"),
|
|
23
|
+
(re.compile(r"sk_live_[0-9a-zA-Z]{24,}"), "Stripe secret key"),
|
|
24
|
+
(re.compile(r"rk_live_[0-9a-zA-Z]{24,}"), "Stripe restricted key"),
|
|
25
|
+
(re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Google API key"),
|
|
26
|
+
(re.compile(r"sk-[0-9a-zA-Z]{20,}T3BlbkFJ[0-9a-zA-Z]+"), "OpenAI API key"),
|
|
27
|
+
(re.compile(r"-----BEGIN\s+(RSA |EC |DSA )?PRIVATE KEY-----"), "private key"),
|
|
28
|
+
(re.compile(r"[a-zA-Z+]+://[^:]+:[^@]+@[^\s]+"), "credentials in URL"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# False-positive skip heuristics
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_FP_WORDS = re.compile(
|
|
36
|
+
r"(example|fake|test|dummy|placeholder|xxxx|TODO|CHANGEME)",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_false_positive(line: str) -> bool:
|
|
42
|
+
"""Return True if the line looks like a placeholder or example."""
|
|
43
|
+
stripped = line.strip()
|
|
44
|
+
if stripped.startswith(("#", "//", "/*", "*")):
|
|
45
|
+
return True
|
|
46
|
+
return bool(_FP_WORDS.search(stripped))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Public API
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _match_line(line: str, lineno: int) -> Smell | None:
|
|
54
|
+
"""Check a single line against all secret patterns."""
|
|
55
|
+
if _is_false_positive(line):
|
|
56
|
+
return None
|
|
57
|
+
for pattern, desc in _PATTERNS:
|
|
58
|
+
if pattern.search(line):
|
|
59
|
+
return Smell(
|
|
60
|
+
"secrets", desc, lineno,
|
|
61
|
+
f"possible {desc} detected",
|
|
62
|
+
FIXES["secrets"],
|
|
63
|
+
)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_secrets(lines: list[str]) -> list[Smell]:
|
|
68
|
+
"""Scan lines for hardcoded secret patterns.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
lines: Source file lines to scan.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of Smell objects for detected secrets.
|
|
75
|
+
"""
|
|
76
|
+
smells: list[Smell] = []
|
|
77
|
+
for lineno, line in enumerate(lines, start=1):
|
|
78
|
+
smell = _match_line(line, lineno)
|
|
79
|
+
if smell is not None:
|
|
80
|
+
smells.append(smell)
|
|
81
|
+
return smells
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Unicode trojan source detection.
|
|
2
|
+
|
|
3
|
+
Detects bidirectional override characters and zero-width characters
|
|
4
|
+
that can be used to disguise malicious code. BOM at file start is allowed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from smell_types import FIXES, Smell
|
|
12
|
+
|
|
13
|
+
# Bidi overrides: U+202A-U+202E
|
|
14
|
+
_BIDI_OVERRIDES = re.compile(r"[\u202A-\u202E]")
|
|
15
|
+
# Bidi isolates: U+2066-U+2069
|
|
16
|
+
_BIDI_ISOLATES = re.compile(r"[\u2066-\u2069]")
|
|
17
|
+
# Zero-width chars: U+200B-U+200F, U+FEFF (BOM)
|
|
18
|
+
_ZERO_WIDTH = re.compile(r"[\u200B-\u200F\uFEFF]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_bidi(line: str, lineno: int) -> Smell | None:
|
|
22
|
+
"""Check a single line for bidi override/isolate characters."""
|
|
23
|
+
if _BIDI_OVERRIDES.search(line) or _BIDI_ISOLATES.search(line):
|
|
24
|
+
return Smell(
|
|
25
|
+
"trojan_bidi", "bidi-override", lineno,
|
|
26
|
+
"Unicode bidi override character detected",
|
|
27
|
+
FIXES["trojan_bidi"],
|
|
28
|
+
)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_zero_width(line: str, lineno: int) -> Smell | None:
|
|
33
|
+
"""Check a single line for zero-width characters."""
|
|
34
|
+
match = _ZERO_WIDTH.search(line)
|
|
35
|
+
if not match:
|
|
36
|
+
return None
|
|
37
|
+
if lineno == 1 and match.start() == 0 and match.group() == "\uFEFF":
|
|
38
|
+
return None
|
|
39
|
+
return Smell(
|
|
40
|
+
"trojan_zero_width", "zero-width-char", lineno,
|
|
41
|
+
"zero-width Unicode character detected",
|
|
42
|
+
FIXES["trojan_zero_width"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_trojan(lines: list[str]) -> list[Smell]:
|
|
47
|
+
"""Scan lines for trojan source Unicode characters.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
lines: Source file lines to scan.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of Smell objects for detected trojan characters.
|
|
54
|
+
"""
|
|
55
|
+
smells: list[Smell] = []
|
|
56
|
+
for lineno, line in enumerate(lines, start=1):
|
|
57
|
+
for checker in (_check_bidi, _check_zero_width):
|
|
58
|
+
result = checker(line, lineno)
|
|
59
|
+
if result is not None:
|
|
60
|
+
smells.append(result)
|
|
61
|
+
return smells
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Write|Edit",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "python3 ~/.claude/hooks/check-complexity.py"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "python3 ~/.claude/hooks/check-security.py"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"PreToolUse": [
|
|
19
|
+
{
|
|
20
|
+
"matcher": "Bash",
|
|
21
|
+
"hooks": [
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "python3 ~/.claude/hooks/check-commit-signing.py"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"Stop": [
|
|
30
|
+
{
|
|
31
|
+
"matcher": "",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "bash ~/.claude/hooks/tab-color.sh green"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"UserPromptSubmit": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "",
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "bash ~/.claude/hooks/tab-color.sh blue"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Code smell detection -- file-level checks, lizard checks, orchestration.
|
|
2
|
+
|
|
3
|
+
Delegates Python AST analysis to smell_python, JS/TS analysis to
|
|
4
|
+
smell_javascript, and shared types to smell_types. This module
|
|
5
|
+
provides the public API: check_file() and format_violations().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from config import Config, DEFAULT_CONFIG, is_file_suppressed, load_config
|
|
13
|
+
from suppression import filter_suppressed
|
|
14
|
+
from smell_types import (
|
|
15
|
+
DUPLICATE_MIN_LINES,
|
|
16
|
+
DUPLICATE_MIN_OCCURRENCES,
|
|
17
|
+
FIXES,
|
|
18
|
+
MAX_PARAMETERS,
|
|
19
|
+
MAX_COMPLEXITY,
|
|
20
|
+
MAX_FUNCTION_LINES,
|
|
21
|
+
Smell,
|
|
22
|
+
apply_threshold_checks,
|
|
23
|
+
)
|
|
24
|
+
from smell_javascript import check_javascript
|
|
25
|
+
from smell_python import check_python
|
|
26
|
+
from smell_ruff import RUFF_EXTS, check_ruff
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# File-level: length
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def check_file_length(
|
|
33
|
+
file_path: str, lines: list[str], config: Config | None = None,
|
|
34
|
+
) -> list[Smell]:
|
|
35
|
+
"""Flag files exceeding the configured max file lines."""
|
|
36
|
+
limit = (config or DEFAULT_CONFIG).max_file_lines
|
|
37
|
+
count = len(lines)
|
|
38
|
+
if count <= limit:
|
|
39
|
+
return []
|
|
40
|
+
return [Smell(
|
|
41
|
+
"long_file", os.path.basename(file_path), 1,
|
|
42
|
+
f"file is {count} lines (max {limit})",
|
|
43
|
+
FIXES["long_file"],
|
|
44
|
+
)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# File-level: duplicate blocks
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_SKIP_PREFIXES = ("import ", "from ", "export ", "require(", "#", "//", "/*")
|
|
52
|
+
_TRIVIAL_LINES = frozenset((
|
|
53
|
+
"{", "}", "(", ")", "[", "]",
|
|
54
|
+
"else:", "else {", "try:", "finally:",
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_trivial(line: str) -> bool:
|
|
59
|
+
"""Return True for lines to skip in duplicate detection."""
|
|
60
|
+
stripped = line.strip()
|
|
61
|
+
if not stripped or stripped in _TRIVIAL_LINES:
|
|
62
|
+
return True
|
|
63
|
+
return stripped.startswith(_SKIP_PREFIXES)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_fingerprints(lines: list[str]) -> dict[tuple[str, ...], list[int]]:
|
|
67
|
+
"""Map fingerprints of consecutive non-trivial lines to positions."""
|
|
68
|
+
entries = [
|
|
69
|
+
(i + 1, line.strip())
|
|
70
|
+
for i, line in enumerate(lines)
|
|
71
|
+
if not _is_trivial(line)
|
|
72
|
+
]
|
|
73
|
+
fps: dict[tuple[str, ...], list[int]] = {}
|
|
74
|
+
for i in range(len(entries) - DUPLICATE_MIN_LINES + 1):
|
|
75
|
+
window = entries[i : i + DUPLICATE_MIN_LINES]
|
|
76
|
+
fp = tuple(e[1] for e in window)
|
|
77
|
+
fps.setdefault(fp, []).append(window[0][0])
|
|
78
|
+
return fps
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _overlaps(locs: list[int], reported: set[int]) -> bool:
|
|
82
|
+
"""Return True if any location overlaps already-reported lines."""
|
|
83
|
+
return any(
|
|
84
|
+
loc + i in reported
|
|
85
|
+
for loc in locs
|
|
86
|
+
for i in range(DUPLICATE_MIN_LINES)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _mark_reported(locs: list[int], reported: set[int]) -> None:
|
|
91
|
+
"""Add all lines covered by locs into the reported set."""
|
|
92
|
+
for loc in locs:
|
|
93
|
+
reported.update(range(loc, loc + DUPLICATE_MIN_LINES))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _make_dup_smell(locs: list[int]) -> Smell:
|
|
97
|
+
"""Create a Smell for a single duplicate block occurrence."""
|
|
98
|
+
loc_str = ", ".join(str(loc) for loc in locs[:4])
|
|
99
|
+
return Smell(
|
|
100
|
+
"duplicate_block", "<repeated-code>", locs[0],
|
|
101
|
+
f"{DUPLICATE_MIN_LINES}-line block repeated at lines {loc_str}",
|
|
102
|
+
FIXES["duplicate_block"],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def check_duplicate_blocks(
|
|
107
|
+
file_path: str, lines: list[str], config: Config | None = None,
|
|
108
|
+
) -> list[Smell]:
|
|
109
|
+
"""Detect repeated consecutive code blocks within a file."""
|
|
110
|
+
fps = _build_fingerprints(lines)
|
|
111
|
+
smells: list[Smell] = []
|
|
112
|
+
reported: set[int] = set()
|
|
113
|
+
for _fp, locs in sorted(fps.items(), key=lambda x: x[1][0]):
|
|
114
|
+
if len(locs) < DUPLICATE_MIN_OCCURRENCES:
|
|
115
|
+
continue
|
|
116
|
+
if _overlaps(locs, reported):
|
|
117
|
+
continue
|
|
118
|
+
_mark_reported(locs, reported)
|
|
119
|
+
smells.append(_make_dup_smell(locs))
|
|
120
|
+
if len(smells) >= 3:
|
|
121
|
+
break
|
|
122
|
+
return smells
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Lizard-based checks (non-JS/TS languages)
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _check_lizard_func(func: object) -> list[Smell]:
|
|
130
|
+
"""Check a single lizard function result for smells."""
|
|
131
|
+
name = getattr(func, "name", "<unknown>")
|
|
132
|
+
line = getattr(func, "start_line", 0)
|
|
133
|
+
return apply_threshold_checks(name, line, [
|
|
134
|
+
("complexity", getattr(func, "cyclomatic_complexity", 0), MAX_COMPLEXITY),
|
|
135
|
+
("long_function", getattr(func, "nloc", 0), MAX_FUNCTION_LINES),
|
|
136
|
+
("too_many_params", len(getattr(func, "parameters", [])), MAX_PARAMETERS),
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_with_lizard(file_path: str) -> list[Smell]:
|
|
141
|
+
"""Use lizard for CC, function length, and parameter count."""
|
|
142
|
+
try:
|
|
143
|
+
import lizard
|
|
144
|
+
except ImportError:
|
|
145
|
+
return []
|
|
146
|
+
try:
|
|
147
|
+
analysis = lizard.analyze_file(file_path)
|
|
148
|
+
except Exception:
|
|
149
|
+
return []
|
|
150
|
+
smells: list[Smell] = []
|
|
151
|
+
for func in analysis.function_list:
|
|
152
|
+
smells.extend(_check_lizard_func(func))
|
|
153
|
+
return smells
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Orchestration
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
PYTHON_EXTS = frozenset((".py",))
|
|
161
|
+
JS_EXTS = frozenset((".js", ".jsx", ".ts", ".tsx"))
|
|
162
|
+
LIZARD_EXTS = frozenset((".java", ".go", ".rs", ".c", ".cpp", ".cs"))
|
|
163
|
+
ALL_EXTS = PYTHON_EXTS | JS_EXTS | LIZARD_EXTS
|
|
164
|
+
SKIP_DIRS = frozenset((
|
|
165
|
+
"node_modules", "__pycache__", ".git", "dist", "build", ".next",
|
|
166
|
+
))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _should_skip(file_path: str) -> bool:
|
|
170
|
+
"""Return True for paths in ignored directories."""
|
|
171
|
+
parts = set(file_path.replace("\\", "/").split("/"))
|
|
172
|
+
return bool(SKIP_DIRS & parts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_source(file_path: str) -> str | None:
|
|
176
|
+
"""Read file contents, returning None on failure."""
|
|
177
|
+
try:
|
|
178
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
179
|
+
return fh.read()
|
|
180
|
+
except OSError:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_lang_checks(ext: str, file_path: str, source: str) -> list[Smell]:
|
|
185
|
+
"""Run language-specific checks based on file extension."""
|
|
186
|
+
if ext in PYTHON_EXTS:
|
|
187
|
+
return check_python(file_path, source)
|
|
188
|
+
if ext in JS_EXTS:
|
|
189
|
+
return check_javascript(file_path, source)
|
|
190
|
+
if ext in LIZARD_EXTS:
|
|
191
|
+
return check_with_lizard(file_path)
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ruff_then_reread(
|
|
196
|
+
ext: str, file_path: str, source: str,
|
|
197
|
+
) -> tuple[list[Smell], str, list[str]]:
|
|
198
|
+
"""Run ruff on Python files and re-read; return smells + fresh source."""
|
|
199
|
+
if ext not in RUFF_EXTS:
|
|
200
|
+
return [], source, source.splitlines()
|
|
201
|
+
smells = check_ruff(file_path)
|
|
202
|
+
fresh = _read_source(file_path)
|
|
203
|
+
if fresh is None:
|
|
204
|
+
return smells, source, source.splitlines()
|
|
205
|
+
return smells, fresh, fresh.splitlines()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_file(file_path: str, config: Config | None = None) -> list[Smell]:
|
|
209
|
+
"""Run all applicable smell checks on a single file."""
|
|
210
|
+
if config is None:
|
|
211
|
+
config = load_config(file_path)
|
|
212
|
+
ext = os.path.splitext(file_path)[1]
|
|
213
|
+
if ext not in ALL_EXTS or _should_skip(file_path):
|
|
214
|
+
return []
|
|
215
|
+
if is_file_suppressed(file_path, config):
|
|
216
|
+
return []
|
|
217
|
+
source = _read_source(file_path)
|
|
218
|
+
if source is None:
|
|
219
|
+
return []
|
|
220
|
+
smells, source, lines = _ruff_then_reread(ext, file_path, source)
|
|
221
|
+
smells.extend(check_file_length(file_path, lines, config))
|
|
222
|
+
smells.extend(check_duplicate_blocks(file_path, lines, config))
|
|
223
|
+
smells.extend(_run_lang_checks(ext, file_path, source))
|
|
224
|
+
return filter_suppressed(smells, lines, "smell")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_violations(file_path: str, smells: list[Smell]) -> str:
|
|
228
|
+
"""Build the feedback message for Claude."""
|
|
229
|
+
header = f"\nCODE SMELL VIOLATIONS in {file_path}:"
|
|
230
|
+
details = []
|
|
231
|
+
for s in smells:
|
|
232
|
+
tag = s.kind.upper().replace("_", " ")
|
|
233
|
+
details.append(f" [{tag}] {s.name}() line {s.line}: {s.detail}")
|
|
234
|
+
fixes = ["\nFix these code smells before moving on:"]
|
|
235
|
+
for fix in dict.fromkeys(s.fix for s in smells):
|
|
236
|
+
fixes.append(f" - {fix}")
|
|
237
|
+
fixes.append("Then notify the user what you fixed and why.")
|
|
238
|
+
return "\n".join([header, *details, *fixes])
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Native JS/TS token-based code smell checks.
|
|
2
|
+
|
|
3
|
+
Detects: cyclomatic complexity, long functions, deep nesting,
|
|
4
|
+
and too-many-parameters in JavaScript and TypeScript source files.
|
|
5
|
+
|
|
6
|
+
Uses a token-based parser (strip comments/strings, regex function
|
|
7
|
+
detection, brace matching) -- zero external dependencies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
from smell_types import (
|
|
16
|
+
MAX_COMPLEXITY,
|
|
17
|
+
MAX_FUNCTION_LINES,
|
|
18
|
+
MAX_NESTING_DEPTH,
|
|
19
|
+
MAX_PARAMETERS,
|
|
20
|
+
Smell,
|
|
21
|
+
apply_threshold_checks,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Layer 1 -- Tokenizer: strip comments and strings
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_COMMENT_OR_STRING = re.compile(
|
|
29
|
+
r"//[^\n]*" # single-line comment
|
|
30
|
+
r"|/\*[\s\S]*?\*/" # multi-line comment
|
|
31
|
+
r"|`(?:[^`\\]|\\.)*`" # template literal
|
|
32
|
+
r'|"(?:[^"\\]|\\.)*"' # double-quoted string
|
|
33
|
+
r"|'(?:[^'\\]|\\.)*'" # single-quoted string
|
|
34
|
+
r"|/(?![*/])(?:[^/\\\n]|\\.)+/[gimsuy]*" # regex literal
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_comments_and_strings(source: str) -> str:
|
|
39
|
+
"""Replace comment/string contents with spaces, preserving lines."""
|
|
40
|
+
def _replacer(match: re.Match[str]) -> str:
|
|
41
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
42
|
+
return _COMMENT_OR_STRING.sub(_replacer, source)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Layer 2 -- Function Finder
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
class _JsFunc(NamedTuple):
|
|
50
|
+
"""A detected JS/TS function."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
start_line: int
|
|
54
|
+
end_line: int
|
|
55
|
+
params_str: str
|
|
56
|
+
body_source: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_MAYBE_ASYNC = r"(?:async\s+)?"
|
|
60
|
+
_MAYBE_EXPORT = r"(?:export\s+)?"
|
|
61
|
+
_MAYBE_GENERICS = r"(?:<[^>]*>)?"
|
|
62
|
+
_MAYBE_RETURN_TYPE = r"(?::\s*[^{]+?)?"
|
|
63
|
+
_IDENT = r"(?P<name>[a-zA-Z_$][a-zA-Z0-9_$]*)"
|
|
64
|
+
_PARAMS = r"\((?P<params>[^)]*)\)"
|
|
65
|
+
_DECL_VAR = r"(?:const|let|var)"
|
|
66
|
+
_MAYBE_TYPE_ANN = r"(?::\s*[^=]+?)?"
|
|
67
|
+
_MAYBE_STAR = r"\*?\s*"
|
|
68
|
+
|
|
69
|
+
# Shared tail: <generics?> (params) <return-type?> {
|
|
70
|
+
_PARAMS_OPEN = r"\s*" + _MAYBE_GENERICS + r"\s*" + _PARAMS + r"\s*" + _MAYBE_RETURN_TYPE
|
|
71
|
+
_VAR_PREFIX = _MAYBE_EXPORT + _DECL_VAR + r"\s+" + _IDENT + r"\s*" + _MAYBE_TYPE_ANN + r"\s*=\s*"
|
|
72
|
+
|
|
73
|
+
# Patterns that detect function declarations in stripped source.
|
|
74
|
+
_FUNC_PATTERNS = [
|
|
75
|
+
# function declaration / async function declaration
|
|
76
|
+
re.compile(
|
|
77
|
+
_MAYBE_EXPORT + _MAYBE_ASYNC + r"function\s*" + _MAYBE_STAR
|
|
78
|
+
+ _IDENT + _PARAMS_OPEN + r"\s*\{"
|
|
79
|
+
),
|
|
80
|
+
# const/let/var name = function(params) {
|
|
81
|
+
re.compile(
|
|
82
|
+
_VAR_PREFIX + _MAYBE_ASYNC + r"function\s*" + _MAYBE_STAR
|
|
83
|
+
+ r"(?:[a-zA-Z_$][a-zA-Z0-9_$]*)?" + _PARAMS_OPEN + r"\s*\{"
|
|
84
|
+
),
|
|
85
|
+
# const/let/var name = (params) => {
|
|
86
|
+
re.compile(
|
|
87
|
+
_VAR_PREFIX + _MAYBE_ASYNC + _MAYBE_GENERICS
|
|
88
|
+
+ r"\s*" + _PARAMS + r"\s*" + _MAYBE_RETURN_TYPE + r"\s*=>\s*\{"
|
|
89
|
+
),
|
|
90
|
+
# class method: name(params) {
|
|
91
|
+
re.compile(
|
|
92
|
+
r"(?:(?:async|static|get|set|public|private|protected"
|
|
93
|
+
r"|readonly|override|abstract)\s+)*"
|
|
94
|
+
+ _IDENT + _PARAMS_OPEN + r"\s*\{"
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _match_brace(source: str, open_pos: int) -> int:
|
|
100
|
+
"""Return index of the closing } that matches the { at open_pos."""
|
|
101
|
+
depth = 1
|
|
102
|
+
for i in range(open_pos + 1, len(source)):
|
|
103
|
+
if source[i] == "{":
|
|
104
|
+
depth += 1
|
|
105
|
+
continue
|
|
106
|
+
if source[i] != "}":
|
|
107
|
+
continue
|
|
108
|
+
depth -= 1
|
|
109
|
+
if depth == 0:
|
|
110
|
+
return i
|
|
111
|
+
return len(source) - 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _line_of(source: str, char_index: int) -> int:
|
|
115
|
+
"""Return 1-based line number for char_index in source."""
|
|
116
|
+
return source[:char_index].count("\n") + 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _overlaps_existing(start: int, ranges: list[tuple[int, int]]) -> bool:
|
|
120
|
+
"""Return True if start falls inside an already-claimed range."""
|
|
121
|
+
return any(r_start <= start < r_end for r_start, r_end in ranges)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _collect_raw_matches(stripped: str) -> list[tuple[int, int, str, str]]:
|
|
125
|
+
"""Scan stripped source for all function matches, deduped by range."""
|
|
126
|
+
found: list[tuple[int, int, str, str]] = []
|
|
127
|
+
used: list[tuple[int, int]] = []
|
|
128
|
+
for pattern in _FUNC_PATTERNS:
|
|
129
|
+
for match in pattern.finditer(stripped):
|
|
130
|
+
start = match.start()
|
|
131
|
+
if _overlaps_existing(start, used):
|
|
132
|
+
continue
|
|
133
|
+
brace_pos = stripped.index("{", match.end() - 1)
|
|
134
|
+
close_pos = _match_brace(stripped, brace_pos)
|
|
135
|
+
found.append((start, close_pos, match.group("name"), match.group("params")))
|
|
136
|
+
used.append((start, close_pos))
|
|
137
|
+
found.sort(key=lambda x: x[0])
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _find_functions(stripped: str) -> list[_JsFunc]:
|
|
142
|
+
"""Find all functions in the stripped source."""
|
|
143
|
+
results: list[_JsFunc] = []
|
|
144
|
+
for start, close, name, params in _collect_raw_matches(stripped):
|
|
145
|
+
start_line = _line_of(stripped, start)
|
|
146
|
+
end_line = _line_of(stripped, close)
|
|
147
|
+
body = stripped[start:close + 1]
|
|
148
|
+
results.append(_JsFunc(name, start_line, end_line, params, body))
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Layer 3 -- Metric functions
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
_CC_KEYWORDS = re.compile(
|
|
157
|
+
r"\b(?:if|else\s+if|case|catch|for|while|do)\b"
|
|
158
|
+
)
|
|
159
|
+
_CC_OPERATORS = re.compile(r"&&|\|\||\?\?|\?\.")
|
|
160
|
+
_CC_TERNARY_REAL = re.compile(r"\?(?![.:])")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _js_complexity(body: str) -> int:
|
|
164
|
+
"""Calculate cyclomatic complexity for a JS/TS function body."""
|
|
165
|
+
cc = 1
|
|
166
|
+
cc += len(_CC_KEYWORDS.findall(body))
|
|
167
|
+
cc += len(_CC_OPERATORS.findall(body))
|
|
168
|
+
cc += len(_CC_TERNARY_REAL.findall(body))
|
|
169
|
+
return cc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _js_func_lines(start: int, end: int) -> int:
|
|
173
|
+
"""Return line count for a function."""
|
|
174
|
+
return end - start + 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _js_nesting_depth(body: str) -> int:
|
|
178
|
+
"""Calculate max nesting depth within a function body."""
|
|
179
|
+
max_depth = 0
|
|
180
|
+
depth = 0
|
|
181
|
+
for ch in body:
|
|
182
|
+
if ch == "{":
|
|
183
|
+
depth += 1
|
|
184
|
+
max_depth = max(max_depth, depth)
|
|
185
|
+
elif ch == "}":
|
|
186
|
+
depth = max(depth - 1, 0)
|
|
187
|
+
# Subtract 1 for the function's own braces
|
|
188
|
+
return max(max_depth - 1, 0)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_TS_TYPE_ANNOTATION = re.compile(r"\s*[?!]?\s*:\s*[^,)]+")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _js_param_count(params_str: str) -> int:
|
|
195
|
+
"""Count parameters, stripping TS type annotations."""
|
|
196
|
+
params = params_str.strip()
|
|
197
|
+
if not params:
|
|
198
|
+
return 0
|
|
199
|
+
cleaned = _TS_TYPE_ANNOTATION.sub("", params)
|
|
200
|
+
parts = [p.strip() for p in cleaned.split(",") if p.strip()]
|
|
201
|
+
return len(parts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Public API
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _check_js_func(func: _JsFunc) -> list[Smell]:
|
|
209
|
+
"""Check a single JS/TS function for all smells."""
|
|
210
|
+
return apply_threshold_checks(func.name, func.start_line, [
|
|
211
|
+
("complexity", _js_complexity(func.body_source), MAX_COMPLEXITY),
|
|
212
|
+
("long_function", _js_func_lines(func.start_line, func.end_line),
|
|
213
|
+
MAX_FUNCTION_LINES),
|
|
214
|
+
("deep_nesting", _js_nesting_depth(func.body_source),
|
|
215
|
+
MAX_NESTING_DEPTH),
|
|
216
|
+
("too_many_params", _js_param_count(func.params_str),
|
|
217
|
+
MAX_PARAMETERS),
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_javascript(file_path: str, source: str) -> list[Smell]:
|
|
222
|
+
"""Run all token-based smell checks on a JS/TS file."""
|
|
223
|
+
try:
|
|
224
|
+
stripped = _strip_comments_and_strings(source)
|
|
225
|
+
funcs = _find_functions(stripped)
|
|
226
|
+
except Exception:
|
|
227
|
+
return []
|
|
228
|
+
smells: list[Smell] = []
|
|
229
|
+
for func in funcs:
|
|
230
|
+
smells.extend(_check_js_func(func))
|
|
231
|
+
return smells
|