@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,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 []
@@ -0,0 +1,72 @@
1
+ """Shared types, thresholds, and helpers for code smell detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Thresholds
9
+ # ---------------------------------------------------------------------------
10
+ MAX_COMPLEXITY = 10
11
+ MAX_FUNCTION_LINES = 20
12
+ MAX_NESTING_DEPTH = 3
13
+ MAX_PARAMETERS = 4
14
+ MAX_FILE_LINES = 300
15
+ DUPLICATE_MIN_LINES = 4
16
+ DUPLICATE_MIN_OCCURRENCES = 2
17
+
18
+ FIXES = {
19
+ "complexity": "Use extract-method, early returns, guard clauses, or lookup tables.",
20
+ "long_function": "Extract helper functions for distinct logical steps.",
21
+ "deep_nesting": "Use guard clauses and early returns to flatten control flow.",
22
+ "too_many_params": "Group related parameters into a dataclass or options object.",
23
+ "duplicate_block": "Extract repeated code into a shared helper function.",
24
+ "long_file": "Split into smaller modules with clear single responsibilities.",
25
+ "secrets": "Move secrets to environment variables or a secrets manager.",
26
+ "B101": "Remove assert from non-test code; use proper validation instead.",
27
+ "B102": "Replace exec/eval with safer alternatives.",
28
+ "B105": "Move hardcoded passwords to environment variables.",
29
+ "B106": "Move hardcoded passwords to environment variables.",
30
+ "B110": "Handle exceptions explicitly instead of using bare except-pass.",
31
+ "B301": "Avoid pickle for untrusted data; use json or safer serialization.",
32
+ "B602": "Avoid shell=True; pass command as a list to subprocess.",
33
+ "trojan_bidi": "Remove Unicode bidi override characters that can disguise code.",
34
+ "trojan_zero_width": "Remove zero-width Unicode characters that hide content.",
35
+ "ruff_lint": "Fix the ruff lint violation manually.",
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Smell:
41
+ """A single code-smell violation."""
42
+
43
+ kind: str
44
+ name: str
45
+ line: int
46
+ detail: str
47
+ fix: str
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Shared helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _DETAIL_TEMPLATES = {
55
+ "complexity": ("complexity={value} (max {max})", MAX_COMPLEXITY),
56
+ "long_function": ("{value} lines (max {max})", MAX_FUNCTION_LINES),
57
+ "deep_nesting": ("nesting depth {value} (max {max})", MAX_NESTING_DEPTH),
58
+ "too_many_params": ("{value} params (max {max})", MAX_PARAMETERS),
59
+ }
60
+
61
+
62
+ def make_smell(kind: str, name: str, line: int, value: int) -> Smell:
63
+ """Create a Smell with a formatted detail string."""
64
+ tpl, mx = _DETAIL_TEMPLATES[kind]
65
+ return Smell(kind, name, line, tpl.format(value=value, max=mx), FIXES[kind])
66
+
67
+
68
+ def apply_threshold_checks(
69
+ name: str, line: int, checks: list[tuple[str, int, int]],
70
+ ) -> list[Smell]:
71
+ """Return Smell for each (kind, value, threshold) where value > threshold."""
72
+ return [make_smell(k, name, line, v) for k, v, t in checks if v > t]
@@ -0,0 +1,82 @@
1
+ """Inline suppression parser for code smell and security hooks.
2
+
3
+ Supports per-line suppression comments:
4
+ # smell: ignore[complexity,long_function]
5
+ # security: ignore[secrets,B101]
6
+
7
+ Applies to the same line or the line immediately following the comment.
8
+ No wildcards supported -- explicit check names required.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import sys
15
+
16
+ from smell_types import Smell
17
+
18
+ _SUPPRESS_RE = re.compile(
19
+ r"#\s*(?P<ns>smell|security):\s*ignore\[(?P<names>[^\]]+)\]"
20
+ )
21
+ _MAX_SUPPRESSIONS_PER_FILE = 5
22
+
23
+
24
+ def _parse_suppression(line: str) -> dict[str, set[str]]:
25
+ """Parse suppression directives from a single line.
26
+
27
+ Returns:
28
+ Dict mapping namespace to set of suppressed check names.
29
+ """
30
+ result: dict[str, set[str]] = {}
31
+ for match in _SUPPRESS_RE.finditer(line):
32
+ ns = match.group("ns")
33
+ names = {n.strip() for n in match.group("names").split(",")}
34
+ result.setdefault(ns, set()).update(names)
35
+ return result
36
+
37
+
38
+ def _build_suppression_map(lines: list[str]) -> dict[int, dict[str, set[str]]]:
39
+ """Build a map of line numbers to their active suppressions.
40
+
41
+ A suppression on line N applies to line N and line N+1.
42
+ """
43
+ suppression_map: dict[int, dict[str, set[str]]] = {}
44
+ for lineno, line in enumerate(lines, start=1):
45
+ parsed = _parse_suppression(line)
46
+ if not parsed:
47
+ continue
48
+ for target_line in (lineno, lineno + 1):
49
+ existing = suppression_map.setdefault(target_line, {})
50
+ for ns, names in parsed.items():
51
+ existing.setdefault(ns, set()).update(names)
52
+ return suppression_map
53
+
54
+
55
+ def _is_suppressed(smell: Smell, namespace: str, active: dict[str, set[str]]) -> bool:
56
+ """Check if a smell is suppressed by the active suppressions."""
57
+ names = active.get(namespace, set())
58
+ return smell.kind in names or smell.name in names
59
+
60
+
61
+ def _warn_excessive(count: int, total_lines: int) -> None:
62
+ """Warn to stderr if too many suppressions are used."""
63
+ if count > _MAX_SUPPRESSIONS_PER_FILE:
64
+ print(
65
+ f"Warning: {count} suppression comments in file "
66
+ f"({_MAX_SUPPRESSIONS_PER_FILE} max recommended)",
67
+ file=sys.stderr,
68
+ )
69
+
70
+
71
+ def filter_suppressed(
72
+ smells: list[Smell], lines: list[str], namespace: str,
73
+ ) -> list[Smell]:
74
+ """Remove smells covered by inline suppression comments."""
75
+ suppression_map = _build_suppression_map(lines)
76
+ _warn_excessive(len(suppression_map), len(lines))
77
+ result: list[Smell] = []
78
+ for smell in smells:
79
+ active = suppression_map.get(smell.line, {})
80
+ if not _is_suppressed(smell, namespace, active):
81
+ result.append(smell)
82
+ return result
@@ -0,0 +1,70 @@
1
+ #!/bin/bash
2
+ # tab-color.sh - Sets iTerm2 tab color for Claude Code hook events
3
+ # Usage: tab-color.sh [gray|blue|green|red|reset]
4
+ #
5
+ # gray = session idle (no prompt running)
6
+ # blue = prompt in progress (working)
7
+ # green = prompt complete (done) -- decays to gray after 3 minutes
8
+ # red = error
9
+ # reset = restore default tab color
10
+ #
11
+ # Tab TITLE (directory name) is set by claude-wrapper.sh at launch.
12
+ # This script only manages COLOR -- Claude Code overrides mid-session title changes.
13
+
14
+ DECAY_SECONDS=180
15
+
16
+ # Consume stdin (hook protocol sends JSON on stdin)
17
+ cat > /dev/null
18
+
19
+ # Per-session state file to prevent decay timer from overwriting blue
20
+ get_state_file() {
21
+ local tty_id
22
+ tty_id=$(tty < /dev/tty 2>/dev/null | tr '/' '_' || echo "unknown")
23
+ echo "/tmp/claude-tab-state${tty_id}"
24
+ }
25
+
26
+ set_tab_color() {
27
+ printf "\033]6;1;bg;red;brightness;%s\a" "$1" > /dev/tty 2>/dev/null
28
+ printf "\033]6;1;bg;green;brightness;%s\a" "$2" > /dev/tty 2>/dev/null
29
+ printf "\033]6;1;bg;blue;brightness;%s\a" "$3" > /dev/tty 2>/dev/null
30
+ }
31
+
32
+ STATE_FILE=$(get_state_file)
33
+
34
+ case "${1:-blue}" in
35
+ gray)
36
+ echo "gray" > "$STATE_FILE" 2>/dev/null
37
+ set_tab_color 130 130 130
38
+ ;;
39
+ blue)
40
+ echo "blue" > "$STATE_FILE" 2>/dev/null
41
+ set_tab_color 20 80 255
42
+ ;;
43
+ green)
44
+ echo "green" > "$STATE_FILE" 2>/dev/null
45
+ set_tab_color 0 255 0
46
+ # Ring terminal bell -- triggers iTerm2 notification for background tabs
47
+ printf "\a" > /dev/tty 2>/dev/null
48
+ # Auto-decay to gray after 3 minutes (only if still green)
49
+ # Fully detach: redirect all fds so Claude Code doesn't wait for child
50
+ nohup bash -c "
51
+ sleep $DECAY_SECONDS
52
+ state_file='$STATE_FILE'
53
+ if [[ -f \"\$state_file\" && \"\$(cat \"\$state_file\" 2>/dev/null)\" == 'green' ]]; then
54
+ echo 'gray' > \"\$state_file\"
55
+ printf '\033]6;1;bg;red;brightness;130\a' > /dev/tty 2>/dev/null
56
+ printf '\033]6;1;bg;green;brightness;130\a' > /dev/tty 2>/dev/null
57
+ printf '\033]6;1;bg;blue;brightness;130\a' > /dev/tty 2>/dev/null
58
+ fi
59
+ " </dev/null >/dev/null 2>&1 &
60
+ ;;
61
+ red)
62
+ echo "red" > "$STATE_FILE" 2>/dev/null
63
+ set_tab_color 220 0 0
64
+ printf "\a" > /dev/tty 2>/dev/null
65
+ ;;
66
+ reset)
67
+ rm -f "$STATE_FILE" 2>/dev/null
68
+ printf "\033]6;1;bg;*;default\a" > /dev/tty 2>/dev/null
69
+ ;;
70
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paulduvall/claude-dev-toolkit",
3
- "version": "0.0.1-alpha.19",
3
+ "version": "0.0.1-alpha.21",
4
4
  "description": "Custom commands toolkit for Claude Code - streamline your development workflow",
5
5
  "author": "Paul Duvall",
6
6
  "license": "MIT",