@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.
@@ -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