@paulduvall/claude-dev-toolkit 0.0.1-alpha.2 → 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/LICENSE +21 -0
- package/README.md +88 -37
- package/bin/claude-commands +307 -65
- package/commands/active/xarchitecture.md +393 -0
- package/commands/active/xconfig.md +127 -0
- package/commands/active/xcontinue.md +92 -0
- package/commands/active/xdebug.md +130 -0
- package/commands/active/xdocs.md +178 -0
- package/commands/active/xexplore.md +94 -0
- package/commands/active/xgit.md +149 -0
- package/commands/active/xpipeline.md +152 -0
- package/commands/active/xquality.md +96 -0
- package/commands/active/xrefactor.md +198 -0
- package/commands/active/xrelease.md +142 -0
- package/commands/active/xsecurity.md +92 -0
- package/commands/active/xspec.md +174 -0
- package/commands/active/xtdd.md +151 -0
- package/commands/active/xtest.md +89 -0
- package/commands/active/xverify.md +80 -0
- package/commands/experiments/xact.md +742 -0
- package/commands/experiments/xanalytics.md +113 -0
- package/commands/experiments/xanalyze.md +70 -0
- package/commands/experiments/xapi.md +161 -0
- package/commands/experiments/xatomic.md +112 -0
- package/commands/experiments/xaws.md +85 -0
- package/commands/experiments/xcicd.md +337 -0
- package/commands/experiments/xcommit.md +122 -0
- package/commands/experiments/xcompliance.md +182 -0
- package/commands/experiments/xconstraints.md +89 -0
- package/commands/experiments/xcoverage.md +90 -0
- package/commands/experiments/xdb.md +102 -0
- package/commands/experiments/xdesign.md +121 -0
- package/commands/experiments/xdevcontainer.md +238 -0
- package/commands/experiments/xevaluate.md +111 -0
- package/commands/experiments/xfootnote.md +12 -0
- package/commands/experiments/xgenerate.md +117 -0
- package/commands/experiments/xgovernance.md +149 -0
- package/commands/experiments/xgreen.md +66 -0
- package/commands/experiments/xiac.md +118 -0
- package/commands/experiments/xincident.md +137 -0
- package/commands/experiments/xinfra.md +115 -0
- package/commands/experiments/xknowledge.md +115 -0
- package/commands/experiments/xmaturity.md +120 -0
- package/commands/experiments/xmetrics.md +118 -0
- package/commands/experiments/xmonitoring.md +128 -0
- package/commands/experiments/xnew.md +903 -0
- package/commands/experiments/xobservable.md +114 -0
- package/commands/experiments/xoidc.md +165 -0
- package/commands/experiments/xoptimize.md +115 -0
- package/commands/experiments/xperformance.md +112 -0
- package/commands/experiments/xplanning.md +131 -0
- package/commands/experiments/xpolicy.md +115 -0
- package/commands/experiments/xproduct.md +98 -0
- package/commands/experiments/xreadiness.md +75 -0
- package/commands/experiments/xred.md +55 -0
- package/commands/experiments/xrisk.md +128 -0
- package/commands/experiments/xrules.md +124 -0
- package/commands/experiments/xsandbox.md +120 -0
- package/commands/experiments/xscan.md +102 -0
- package/commands/experiments/xsetup.md +123 -0
- package/commands/experiments/xtemplate.md +116 -0
- package/commands/experiments/xtrace.md +212 -0
- package/commands/experiments/xux.md +171 -0
- package/commands/experiments/xvalidate.md +104 -0
- package/commands/experiments/xworkflow.md +113 -0
- package/hooks/.smellrc.example.json +19 -0
- package/hooks/README.md +263 -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/file-logger.sh +100 -0
- package/hooks/lib/argument-parser.sh +427 -0
- package/hooks/lib/config-constants.sh +230 -0
- package/hooks/lib/context-manager.sh +560 -0
- package/hooks/lib/error-handler.sh +423 -0
- package/hooks/lib/execution-engine.sh +444 -0
- package/hooks/lib/execution-results.sh +113 -0
- package/hooks/lib/execution-simulation.sh +114 -0
- package/hooks/lib/field-validators.sh +104 -0
- package/hooks/lib/file-utils.sh +398 -0
- package/hooks/lib/subagent-discovery.sh +468 -0
- package/hooks/lib/subagent-validator.sh +407 -0
- package/hooks/lib/validation-reporter.sh +134 -0
- package/hooks/on-error-debug.sh +226 -0
- package/hooks/pre-commit-quality.sh +204 -0
- package/hooks/pre-commit-test-runner.sh +132 -0
- package/hooks/pre-write-security.sh +115 -0
- package/hooks/prevent-credential-exposure.sh +279 -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/subagent-trigger-simple.sh +202 -0
- package/hooks/subagent-trigger.sh +253 -0
- package/hooks/suppression.py +82 -0
- package/hooks/tab-color.sh +70 -0
- package/hooks/verify-before-edit.sh +135 -0
- package/lib/backup-restore-command.js +140 -0
- package/lib/base/base-command.js +252 -0
- package/lib/base/command-result.js +184 -0
- package/lib/config/constants.js +255 -0
- package/lib/config.js +48 -6
- package/lib/configure-command.js +428 -0
- package/lib/dependency-validator.js +64 -5
- package/lib/hook-installer-core.js +2 -2
- package/lib/installation-instruction-generator.js +213 -495
- package/lib/installer.js +134 -56
- package/lib/oidc-command.js +740 -0
- package/lib/services/backup-list-service.js +226 -0
- package/lib/services/backup-service.js +230 -0
- package/lib/services/command-installer-service.js +217 -0
- package/lib/services/logger-service.js +201 -0
- package/lib/services/package-manager-service.js +319 -0
- package/lib/services/platform-instruction-service.js +294 -0
- package/lib/services/recovery-instruction-service.js +348 -0
- package/lib/services/restore-service.js +221 -0
- package/lib/setup-command.js +359 -0
- package/lib/setup-wizard.js +155 -262
- package/lib/uninstall-command.js +100 -0
- package/lib/utils/claude-path-config.js +184 -0
- package/lib/utils/file-system-utils.js +152 -0
- package/lib/utils.js +8 -4
- package/lib/verify-command.js +430 -0
- package/package.json +7 -3
- package/scripts/postinstall.js +172 -157
- package/subagents/debug-specialist.md +7 -0
- package/templates/README.md +115 -0
- package/templates/basic-settings.json +30 -0
- package/templates/comprehensive-settings.json +57 -0
- package/templates/global-claude.md +344 -0
- package/templates/hybrid-hook-config.yaml +132 -0
- package/templates/security-focused-settings.json +62 -0
- package/templates/subagent-hooks.yaml +188 -0
- package/lib/package-manager-service.js +0 -270
- package/subagents/debug-context.md +0 -197
|
@@ -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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Python AST-based code smell checks.
|
|
2
|
+
|
|
3
|
+
Detects: cyclomatic complexity, long functions, deep nesting,
|
|
4
|
+
and too-many-parameters in Python source files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
|
|
11
|
+
from smell_types import (
|
|
12
|
+
MAX_COMPLEXITY,
|
|
13
|
+
MAX_FUNCTION_LINES,
|
|
14
|
+
MAX_NESTING_DEPTH,
|
|
15
|
+
MAX_PARAMETERS,
|
|
16
|
+
Smell,
|
|
17
|
+
apply_threshold_checks,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_NESTING_TYPES: tuple[type, ...] = (
|
|
21
|
+
ast.If, ast.For, ast.AsyncFor, ast.While,
|
|
22
|
+
ast.Try, ast.With, ast.AsyncWith,
|
|
23
|
+
)
|
|
24
|
+
if hasattr(ast, "Match"):
|
|
25
|
+
_NESTING_TYPES = (*_NESTING_TYPES, ast.Match)
|
|
26
|
+
if hasattr(ast, "TryStar"):
|
|
27
|
+
_NESTING_TYPES = (*_NESTING_TYPES, ast.TryStar)
|
|
28
|
+
|
|
29
|
+
_CC_DECISION_TYPES: dict[type, str] = {
|
|
30
|
+
ast.If: "single", ast.IfExp: "single",
|
|
31
|
+
ast.For: "single", ast.AsyncFor: "single", ast.While: "single",
|
|
32
|
+
ast.ExceptHandler: "single",
|
|
33
|
+
ast.With: "single", ast.AsyncWith: "single",
|
|
34
|
+
ast.Assert: "single",
|
|
35
|
+
ast.BoolOp: "boolop",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _py_nesting_depth(node: ast.AST) -> int:
|
|
40
|
+
"""Return the maximum nesting depth within *node*."""
|
|
41
|
+
max_depth = 0
|
|
42
|
+
|
|
43
|
+
def _visit(current: ast.AST, depth: int) -> None:
|
|
44
|
+
nonlocal max_depth
|
|
45
|
+
max_depth = max(max_depth, depth)
|
|
46
|
+
for child in ast.iter_child_nodes(current):
|
|
47
|
+
inc = 1 if isinstance(child, _NESTING_TYPES) else 0
|
|
48
|
+
_visit(child, depth + inc)
|
|
49
|
+
|
|
50
|
+
_visit(node, 0)
|
|
51
|
+
return max_depth
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _py_func_lines(node: ast.AST) -> int:
|
|
55
|
+
"""Return line span of a function node."""
|
|
56
|
+
start = getattr(node, "lineno", None)
|
|
57
|
+
end = getattr(node, "end_lineno", None)
|
|
58
|
+
if isinstance(start, int) and isinstance(end, int):
|
|
59
|
+
return end - start + 1
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _py_param_count(node: ast.FunctionDef) -> int:
|
|
64
|
+
"""Count user-facing parameters (excludes self/cls)."""
|
|
65
|
+
args = node.args
|
|
66
|
+
count = len(args.args) + len(args.posonlyargs) + len(args.kwonlyargs)
|
|
67
|
+
if args.vararg:
|
|
68
|
+
count += 1
|
|
69
|
+
if args.kwarg:
|
|
70
|
+
count += 1
|
|
71
|
+
if args.args and args.args[0].arg in ("self", "cls"):
|
|
72
|
+
count -= 1
|
|
73
|
+
return count
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _py_complexity(node: ast.AST) -> int:
|
|
77
|
+
"""Calculate cyclomatic complexity for a Python function."""
|
|
78
|
+
cc = 1
|
|
79
|
+
for child in ast.walk(node):
|
|
80
|
+
kind = _CC_DECISION_TYPES.get(type(child))
|
|
81
|
+
if kind == "single":
|
|
82
|
+
cc += 1
|
|
83
|
+
elif kind == "boolop":
|
|
84
|
+
cc += len(child.values) - 1
|
|
85
|
+
return cc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_py_func(node: ast.AST) -> list[Smell]:
|
|
89
|
+
"""Check a single Python function for all smells."""
|
|
90
|
+
name: str = getattr(node, "name", "<unknown>")
|
|
91
|
+
line: int = getattr(node, "lineno", 0)
|
|
92
|
+
return apply_threshold_checks(name, line, [
|
|
93
|
+
("complexity", _py_complexity(node), MAX_COMPLEXITY),
|
|
94
|
+
("long_function", _py_func_lines(node), MAX_FUNCTION_LINES),
|
|
95
|
+
("deep_nesting", _py_nesting_depth(node), MAX_NESTING_DEPTH),
|
|
96
|
+
("too_many_params", _py_param_count(node), MAX_PARAMETERS),
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_python(file_path: str, source: str) -> list[Smell]:
|
|
101
|
+
"""Run all AST-based smell checks on a Python file."""
|
|
102
|
+
try:
|
|
103
|
+
tree = ast.parse(source, filename=file_path)
|
|
104
|
+
except SyntaxError:
|
|
105
|
+
return []
|
|
106
|
+
smells: list[Smell] = []
|
|
107
|
+
for node in ast.walk(tree):
|
|
108
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
109
|
+
smells.extend(_check_py_func(node))
|
|
110
|
+
return smells
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Ruff lint + format integration for code smell hooks.
|
|
2
|
+
|
|
3
|
+
Auto-fixes fixable issues (unused imports, formatting), then reports
|
|
4
|
+
any remaining unfixable violations as blocking smells.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from smell_types import Smell
|
|
14
|
+
|
|
15
|
+
RUFF_EXTS = frozenset((".py",))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ruff_cmd(project_root: str | None = None) -> list[str] | None:
|
|
19
|
+
"""Return command prefix for ruff, or None if unavailable."""
|
|
20
|
+
if shutil.which("ruff"):
|
|
21
|
+
return ["ruff"]
|
|
22
|
+
if project_root:
|
|
23
|
+
venv_ruff = os.path.join(project_root, ".venv", "bin", "ruff")
|
|
24
|
+
if os.path.isfile(venv_ruff):
|
|
25
|
+
return [venv_ruff]
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_ruff_cmd(cmd: list[str], cwd: str | None = None) -> tuple[int, str]:
|
|
30
|
+
"""Run a ruff command in the given directory."""
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
cmd, capture_output=True, text=True, timeout=30, cwd=cwd,
|
|
34
|
+
)
|
|
35
|
+
return result.returncode, (result.stdout + result.stderr).strip()
|
|
36
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
37
|
+
return 0, ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_ruff_output(output: str) -> list[Smell]:
|
|
41
|
+
"""Convert remaining ruff output lines into Smell objects."""
|
|
42
|
+
return [
|
|
43
|
+
Smell("ruff_lint", "<ruff>", 0, line.strip(),
|
|
44
|
+
"Fix the ruff lint violation manually.")
|
|
45
|
+
for line in output.splitlines()
|
|
46
|
+
if line.strip() and not line.strip().startswith("Found ")
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_project_root(file_path: str) -> str | None:
|
|
51
|
+
"""Walk up from file_path to find a pyproject.toml directory."""
|
|
52
|
+
directory = os.path.dirname(os.path.abspath(file_path))
|
|
53
|
+
while directory != os.path.dirname(directory):
|
|
54
|
+
if os.path.isfile(os.path.join(directory, "pyproject.toml")):
|
|
55
|
+
return directory
|
|
56
|
+
directory = os.path.dirname(directory)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_ruff(file_path: str) -> list[Smell]:
|
|
61
|
+
"""Auto-fix with ruff, then report any remaining unfixable issues."""
|
|
62
|
+
cwd = _find_project_root(file_path)
|
|
63
|
+
prefix = _ruff_cmd(cwd)
|
|
64
|
+
if not prefix:
|
|
65
|
+
return []
|
|
66
|
+
_run_ruff_cmd(prefix + ["check", "--fix", "--quiet", file_path], cwd)
|
|
67
|
+
_run_ruff_cmd(prefix + ["format", "--quiet", file_path], cwd)
|
|
68
|
+
check_cmd = prefix + ["check", "--output-format", "concise", file_path]
|
|
69
|
+
code, out = _run_ruff_cmd(check_cmd, cwd)
|
|
70
|
+
return _parse_ruff_output(out) if code != 0 else []
|