Skip to content

Commit 627773a

Browse files
committed
feat(plugin): add automatic checklist verification on pre-commit
When git commit is detected, analyze staged files and auto-select relevant checklist domains (security, accessibility, performance). Shows top 3-5 items as non-blocking warning in additionalContext. Closes #1001
1 parent 415606f commit 627773a

3 files changed

Lines changed: 381 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Pre-commit checklist verifier — maps changed files to quality checklists.
2+
3+
Analyzes staged files and auto-selects relevant checklist domains:
4+
- auth/, login, password, token, session, oauth, jwt → security
5+
- .css, .scss, .tsx + ARIA content → accessibility
6+
- api/, controllers/, endpoints/, routes/ → performance
7+
8+
Shows top 3-5 items as a non-blocking warning.
9+
See: https://github.com/JeremyDev87/codingbuddy/issues/1001
10+
"""
11+
import os
12+
import re
13+
from typing import Dict, List, Optional
14+
15+
# Test file patterns to exclude from domain detection
16+
_TEST_FILE_RE = re.compile(r"\.(spec|test)\.")
17+
18+
# --- Domain detection rules ---
19+
20+
_SECURITY_PATH_PATTERNS = [
21+
re.compile(r"(?:^|/)auth/", re.IGNORECASE),
22+
re.compile(r"(?:^|/)login", re.IGNORECASE),
23+
re.compile(r"(?:^|/)password", re.IGNORECASE),
24+
re.compile(r"(?:^|/)token", re.IGNORECASE),
25+
re.compile(r"(?:^|/)session", re.IGNORECASE),
26+
re.compile(r"(?:^|/)oauth", re.IGNORECASE),
27+
re.compile(r"(?:^|/)jwt", re.IGNORECASE),
28+
]
29+
30+
_ACCESSIBILITY_EXTENSIONS = {".css", ".scss"}
31+
32+
_PERFORMANCE_PATH_PATTERNS = [
33+
re.compile(r"(?:^|/)api/", re.IGNORECASE),
34+
re.compile(r"(?:^|/)controllers?/", re.IGNORECASE),
35+
re.compile(r"(?:^|/)endpoints?/", re.IGNORECASE),
36+
re.compile(r"(?:^|/)routes?/", re.IGNORECASE),
37+
re.compile(r"\.controller\.", re.IGNORECASE),
38+
]
39+
40+
# --- Checklist items per domain (top 3-5 high-impact items) ---
41+
42+
_CHECKLIST_ITEMS: Dict[str, List[str]] = {
43+
"security": [
44+
"Validate and sanitize all user inputs",
45+
"Ensure authentication tokens are not exposed in logs",
46+
"Check for proper authorization on protected routes",
47+
"Verify secrets are not hardcoded in source code",
48+
"Review for injection vulnerabilities (SQL, XSS, command)",
49+
],
50+
"accessibility": [
51+
"Ensure all interactive elements have accessible labels (aria-label/aria-labelledby)",
52+
"Verify color contrast meets WCAG 2.1 AA (4.5:1 for text)",
53+
"Check keyboard navigation works for all interactive elements",
54+
"Ensure focus indicators are visible and not removed",
55+
],
56+
"performance": [
57+
"Check for N+1 query patterns in data fetching",
58+
"Verify proper pagination for list endpoints",
59+
"Ensure appropriate caching headers are set",
60+
"Review payload size — avoid returning unnecessary fields",
61+
],
62+
}
63+
64+
65+
class ChecklistVerifier:
66+
"""Maps changed files to relevant quality checklist domains."""
67+
68+
def detect_domains(
69+
self,
70+
changed_files: List[str],
71+
file_contents: Optional[Dict[str, str]] = None,
72+
) -> List[str]:
73+
"""Detect relevant checklist domains from changed file paths.
74+
75+
Args:
76+
changed_files: List of changed file paths.
77+
file_contents: Optional map of filepath → content for content-based
78+
detection (e.g., ARIA attributes in .tsx files).
79+
80+
Returns:
81+
Deduplicated list of domain names.
82+
"""
83+
if not changed_files:
84+
return []
85+
86+
domains: set = set()
87+
file_contents = file_contents or {}
88+
89+
for filepath in changed_files:
90+
# Skip test files
91+
if _TEST_FILE_RE.search(filepath):
92+
continue
93+
94+
ext = os.path.splitext(filepath)[1].lower()
95+
96+
# Security domain
97+
for pattern in _SECURITY_PATH_PATTERNS:
98+
if pattern.search(filepath):
99+
domains.add("security")
100+
break
101+
102+
# Accessibility domain
103+
if ext in _ACCESSIBILITY_EXTENSIONS:
104+
domains.add("accessibility")
105+
elif ext == ".tsx":
106+
content = file_contents.get(filepath, "")
107+
if content and re.search(r"aria-", content):
108+
domains.add("accessibility")
109+
110+
# Performance domain
111+
for pattern in _PERFORMANCE_PATH_PATTERNS:
112+
if pattern.search(filepath):
113+
domains.add("performance")
114+
break
115+
116+
return sorted(domains)
117+
118+
def get_checklist_items(self, domain: str) -> List[str]:
119+
"""Return checklist items for a given domain.
120+
121+
Args:
122+
domain: One of 'security', 'accessibility', 'performance'.
123+
124+
Returns:
125+
List of checklist item strings, empty for unknown domains.
126+
"""
127+
return list(_CHECKLIST_ITEMS.get(domain, []))
128+
129+
def format_warning(self, domain_items: Dict[str, List[str]]) -> str:
130+
"""Format checklist items as a non-blocking warning message.
131+
132+
Args:
133+
domain_items: Map of domain → checklist items.
134+
135+
Returns:
136+
Formatted warning string, empty if no items.
137+
"""
138+
if not domain_items:
139+
return ""
140+
141+
lines = ["[CodingBuddy Checklist] Pre-commit review (non-blocking):"]
142+
for domain, items in domain_items.items():
143+
lines.append(f" [{domain.upper()}]")
144+
for item in items:
145+
lines.append(f" - {item}")
146+
return "\n".join(lines)
147+
148+
def verify(
149+
self,
150+
changed_files: List[str],
151+
file_contents: Optional[Dict[str, str]] = None,
152+
) -> Optional[str]:
153+
"""Full verification flow: detect domains → get items → format warning.
154+
155+
Args:
156+
changed_files: List of staged file paths.
157+
file_contents: Optional file content map for content-based checks.
158+
159+
Returns:
160+
Warning string or None if no relevant checklists.
161+
"""
162+
if not changed_files:
163+
return None
164+
165+
domains = self.detect_domains(changed_files, file_contents)
166+
if not domains:
167+
return None
168+
169+
domain_items: Dict[str, List[str]] = {}
170+
for domain in domains:
171+
items = self.get_checklist_items(domain)
172+
if items:
173+
domain_items[domain] = items
174+
175+
if not domain_items:
176+
return None
177+
178+
return self.format_warning(domain_items)

packages/claude-code-plugin/hooks/pre-tool-use.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Intercepts Bash tool calls to enforce quality gates on git commit commands.
55
Detects .ai-rules/config changes via FileWatcher (#823).
66
Suggests related tests for staged files via SmartTestRunner (#944).
7+
Auto-selects relevant checklists for staged files via ChecklistVerifier (#1001).
78
Displays active agent status in spinner via statusMessage (#974).
89
Uses safe_main decorator to ensure Claude Code is never blocked.
910
"""
@@ -154,6 +155,17 @@ def _get_test_suggestion(staged_files: List[str]) -> Optional[str]:
154155
return None
155156

156157

158+
def _get_checklist_warning(staged_files: List[str]) -> Optional[str]:
159+
"""Use ChecklistVerifier to build a checklist warning for staged files (#1001)."""
160+
try:
161+
from checklist_verifier import ChecklistVerifier
162+
163+
verifier = ChecklistVerifier()
164+
return verifier.verify(staged_files)
165+
except Exception:
166+
return None
167+
168+
157169
def _handle(data: dict) -> Optional[dict]:
158170
"""Core PreToolUse logic.
159171
@@ -192,6 +204,11 @@ def _handle(data: dict) -> Optional[dict]:
192204
if suggestion:
193205
contexts.append(suggestion)
194206

207+
# Checklist verifier — auto-select relevant checklists (#1001)
208+
checklist_warning = _get_checklist_warning(staged)
209+
if checklist_warning:
210+
contexts.append(checklist_warning)
211+
195212
# Build response — include statusMessage and/or additionalContext
196213
if not status_msg and not contexts:
197214
return None

0 commit comments

Comments
 (0)