diff --git a/packages/claude-code-plugin/hooks/lib/checklist_verifier.py b/packages/claude-code-plugin/hooks/lib/checklist_verifier.py new file mode 100644 index 00000000..1b34ddc3 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/checklist_verifier.py @@ -0,0 +1,178 @@ +"""Pre-commit checklist verifier — maps changed files to quality checklists. + +Analyzes staged files and auto-selects relevant checklist domains: +- auth/, login, password, token, session, oauth, jwt → security +- .css, .scss, .tsx + ARIA content → accessibility +- api/, controllers/, endpoints/, routes/ → performance + +Shows top 3-5 items as a non-blocking warning. +See: https://github.com/JeremyDev87/codingbuddy/issues/1001 +""" +import os +import re +from typing import Dict, List, Optional + +# Test file patterns to exclude from domain detection +_TEST_FILE_RE = re.compile(r"\.(spec|test)\.") + +# --- Domain detection rules --- + +_SECURITY_PATH_PATTERNS = [ + re.compile(r"(?:^|/)auth/", re.IGNORECASE), + re.compile(r"(?:^|/)login", re.IGNORECASE), + re.compile(r"(?:^|/)password", re.IGNORECASE), + re.compile(r"(?:^|/)token", re.IGNORECASE), + re.compile(r"(?:^|/)session", re.IGNORECASE), + re.compile(r"(?:^|/)oauth", re.IGNORECASE), + re.compile(r"(?:^|/)jwt", re.IGNORECASE), +] + +_ACCESSIBILITY_EXTENSIONS = {".css", ".scss"} + +_PERFORMANCE_PATH_PATTERNS = [ + re.compile(r"(?:^|/)api/", re.IGNORECASE), + re.compile(r"(?:^|/)controllers?/", re.IGNORECASE), + re.compile(r"(?:^|/)endpoints?/", re.IGNORECASE), + re.compile(r"(?:^|/)routes?/", re.IGNORECASE), + re.compile(r"\.controller\.", re.IGNORECASE), +] + +# --- Checklist items per domain (top 3-5 high-impact items) --- + +_CHECKLIST_ITEMS: Dict[str, List[str]] = { + "security": [ + "Validate and sanitize all user inputs", + "Ensure authentication tokens are not exposed in logs", + "Check for proper authorization on protected routes", + "Verify secrets are not hardcoded in source code", + "Review for injection vulnerabilities (SQL, XSS, command)", + ], + "accessibility": [ + "Ensure all interactive elements have accessible labels (aria-label/aria-labelledby)", + "Verify color contrast meets WCAG 2.1 AA (4.5:1 for text)", + "Check keyboard navigation works for all interactive elements", + "Ensure focus indicators are visible and not removed", + ], + "performance": [ + "Check for N+1 query patterns in data fetching", + "Verify proper pagination for list endpoints", + "Ensure appropriate caching headers are set", + "Review payload size — avoid returning unnecessary fields", + ], +} + + +class ChecklistVerifier: + """Maps changed files to relevant quality checklist domains.""" + + def detect_domains( + self, + changed_files: List[str], + file_contents: Optional[Dict[str, str]] = None, + ) -> List[str]: + """Detect relevant checklist domains from changed file paths. + + Args: + changed_files: List of changed file paths. + file_contents: Optional map of filepath → content for content-based + detection (e.g., ARIA attributes in .tsx files). + + Returns: + Deduplicated list of domain names. + """ + if not changed_files: + return [] + + domains: set = set() + file_contents = file_contents or {} + + for filepath in changed_files: + # Skip test files + if _TEST_FILE_RE.search(filepath): + continue + + ext = os.path.splitext(filepath)[1].lower() + + # Security domain + for pattern in _SECURITY_PATH_PATTERNS: + if pattern.search(filepath): + domains.add("security") + break + + # Accessibility domain + if ext in _ACCESSIBILITY_EXTENSIONS: + domains.add("accessibility") + elif ext == ".tsx": + content = file_contents.get(filepath, "") + if content and re.search(r"aria-", content): + domains.add("accessibility") + + # Performance domain + for pattern in _PERFORMANCE_PATH_PATTERNS: + if pattern.search(filepath): + domains.add("performance") + break + + return sorted(domains) + + def get_checklist_items(self, domain: str) -> List[str]: + """Return checklist items for a given domain. + + Args: + domain: One of 'security', 'accessibility', 'performance'. + + Returns: + List of checklist item strings, empty for unknown domains. + """ + return list(_CHECKLIST_ITEMS.get(domain, [])) + + def format_warning(self, domain_items: Dict[str, List[str]]) -> str: + """Format checklist items as a non-blocking warning message. + + Args: + domain_items: Map of domain → checklist items. + + Returns: + Formatted warning string, empty if no items. + """ + if not domain_items: + return "" + + lines = ["[CodingBuddy Checklist] Pre-commit review (non-blocking):"] + for domain, items in domain_items.items(): + lines.append(f" [{domain.upper()}]") + for item in items: + lines.append(f" - {item}") + return "\n".join(lines) + + def verify( + self, + changed_files: List[str], + file_contents: Optional[Dict[str, str]] = None, + ) -> Optional[str]: + """Full verification flow: detect domains → get items → format warning. + + Args: + changed_files: List of staged file paths. + file_contents: Optional file content map for content-based checks. + + Returns: + Warning string or None if no relevant checklists. + """ + if not changed_files: + return None + + domains = self.detect_domains(changed_files, file_contents) + if not domains: + return None + + domain_items: Dict[str, List[str]] = {} + for domain in domains: + items = self.get_checklist_items(domain) + if items: + domain_items[domain] = items + + if not domain_items: + return None + + return self.format_warning(domain_items) diff --git a/packages/claude-code-plugin/hooks/pre-tool-use.py b/packages/claude-code-plugin/hooks/pre-tool-use.py index 557e0852..cafe10e0 100644 --- a/packages/claude-code-plugin/hooks/pre-tool-use.py +++ b/packages/claude-code-plugin/hooks/pre-tool-use.py @@ -4,6 +4,7 @@ Intercepts Bash tool calls to enforce quality gates on git commit commands. Detects .ai-rules/config changes via FileWatcher (#823). Suggests related tests for staged files via SmartTestRunner (#944). +Auto-selects relevant checklists for staged files via ChecklistVerifier (#1001). Displays active agent status in spinner via statusMessage (#974). Uses safe_main decorator to ensure Claude Code is never blocked. """ @@ -154,6 +155,17 @@ def _get_test_suggestion(staged_files: List[str]) -> Optional[str]: return None +def _get_checklist_warning(staged_files: List[str]) -> Optional[str]: + """Use ChecklistVerifier to build a checklist warning for staged files (#1001).""" + try: + from checklist_verifier import ChecklistVerifier + + verifier = ChecklistVerifier() + return verifier.verify(staged_files) + except Exception: + return None + + def _handle(data: dict) -> Optional[dict]: """Core PreToolUse logic. @@ -192,6 +204,11 @@ def _handle(data: dict) -> Optional[dict]: if suggestion: contexts.append(suggestion) + # Checklist verifier — auto-select relevant checklists (#1001) + checklist_warning = _get_checklist_warning(staged) + if checklist_warning: + contexts.append(checklist_warning) + # Build response — include statusMessage and/or additionalContext if not status_msg and not contexts: return None diff --git a/packages/claude-code-plugin/hooks/tests/test_checklist_verifier.py b/packages/claude-code-plugin/hooks/tests/test_checklist_verifier.py new file mode 100644 index 00000000..81491826 --- /dev/null +++ b/packages/claude-code-plugin/hooks/tests/test_checklist_verifier.py @@ -0,0 +1,186 @@ +"""Tests for checklist_verifier — pre-commit checklist domain detection (#1001).""" +import os +import sys +import unittest + +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "lib") +) + +from checklist_verifier import ChecklistVerifier + + +class TestDomainDetection(unittest.TestCase): + """Test file-pattern to checklist-domain mapping.""" + + def setUp(self): + self.verifier = ChecklistVerifier() + + # --- Security domain --- + + def test_auth_directory_triggers_security(self): + domains = self.verifier.detect_domains(["src/auth/login.ts"]) + self.assertIn("security", domains) + + def test_login_file_triggers_security(self): + domains = self.verifier.detect_domains(["src/features/login.tsx"]) + self.assertIn("security", domains) + + def test_password_file_triggers_security(self): + domains = self.verifier.detect_domains(["src/utils/password-hash.ts"]) + self.assertIn("security", domains) + + def test_token_file_triggers_security(self): + domains = self.verifier.detect_domains(["src/lib/jwt-token.ts"]) + self.assertIn("security", domains) + + def test_oauth_file_triggers_security(self): + domains = self.verifier.detect_domains(["src/auth/oauth-callback.ts"]) + self.assertIn("security", domains) + + def test_session_file_triggers_security(self): + domains = self.verifier.detect_domains(["src/middleware/session.ts"]) + self.assertIn("security", domains) + + # --- Accessibility domain --- + + def test_css_file_triggers_accessibility(self): + domains = self.verifier.detect_domains(["src/styles/main.css"]) + self.assertIn("accessibility", domains) + + def test_scss_file_triggers_accessibility(self): + domains = self.verifier.detect_domains(["src/styles/theme.scss"]) + self.assertIn("accessibility", domains) + + def test_tsx_with_aria_content_triggers_accessibility(self): + domains = self.verifier.detect_domains( + ["src/components/Button.tsx"], + file_contents={"src/components/Button.tsx": "aria-label='submit'"}, + ) + self.assertIn("accessibility", domains) + + def test_tsx_without_aria_no_accessibility(self): + """Plain .tsx without ARIA content should not trigger accessibility.""" + domains = self.verifier.detect_domains(["src/utils/helper.tsx"]) + self.assertNotIn("accessibility", domains) + + # --- Performance domain --- + + def test_api_route_triggers_performance(self): + domains = self.verifier.detect_domains(["src/app/api/users/route.ts"]) + self.assertIn("performance", domains) + + def test_controller_triggers_performance(self): + domains = self.verifier.detect_domains(["src/controllers/order.controller.ts"]) + self.assertIn("performance", domains) + + def test_endpoint_triggers_performance(self): + domains = self.verifier.detect_domains(["src/endpoints/health.ts"]) + self.assertIn("performance", domains) + + # --- Multiple domains --- + + def test_multiple_domains_from_mixed_files(self): + domains = self.verifier.detect_domains([ + "src/auth/login.ts", + "src/app/api/users/route.ts", + "src/styles/main.css", + ]) + self.assertIn("security", domains) + self.assertIn("performance", domains) + self.assertIn("accessibility", domains) + + # --- No domain --- + + def test_unrelated_file_returns_empty(self): + domains = self.verifier.detect_domains(["README.md"]) + self.assertEqual(domains, []) + + def test_test_file_returns_empty(self): + domains = self.verifier.detect_domains(["src/auth/login.spec.ts"]) + self.assertEqual(domains, []) + + +class TestChecklistItems(unittest.TestCase): + """Test checklist item retrieval per domain.""" + + def setUp(self): + self.verifier = ChecklistVerifier() + + def test_security_returns_3_to_5_items(self): + items = self.verifier.get_checklist_items("security") + self.assertGreaterEqual(len(items), 3) + self.assertLessEqual(len(items), 5) + + def test_accessibility_returns_3_to_5_items(self): + items = self.verifier.get_checklist_items("accessibility") + self.assertGreaterEqual(len(items), 3) + self.assertLessEqual(len(items), 5) + + def test_performance_returns_3_to_5_items(self): + items = self.verifier.get_checklist_items("performance") + self.assertGreaterEqual(len(items), 3) + self.assertLessEqual(len(items), 5) + + def test_unknown_domain_returns_empty(self): + items = self.verifier.get_checklist_items("unknown") + self.assertEqual(items, []) + + +class TestFormatWarning(unittest.TestCase): + """Test warning message formatting.""" + + def setUp(self): + self.verifier = ChecklistVerifier() + + def test_format_includes_domain_header(self): + result = self.verifier.format_warning({"security": ["item1", "item2"]}) + self.assertIn("security", result.lower()) + + def test_format_includes_all_items(self): + items = {"security": ["Check input validation", "Verify auth tokens"]} + result = self.verifier.format_warning(items) + self.assertIn("Check input validation", result) + self.assertIn("Verify auth tokens", result) + + def test_format_multiple_domains(self): + items = { + "security": ["item1"], + "performance": ["item2"], + } + result = self.verifier.format_warning(items) + self.assertIn("security", result.lower()) + self.assertIn("performance", result.lower()) + + def test_format_empty_returns_empty(self): + result = self.verifier.format_warning({}) + self.assertEqual(result, "") + + def test_format_is_non_blocking_warning(self): + items = {"security": ["item1"]} + result = self.verifier.format_warning(items) + self.assertIn("non-blocking", result.lower()) + + +class TestVerifyIntegration(unittest.TestCase): + """Integration test for the full verify flow.""" + + def setUp(self): + self.verifier = ChecklistVerifier() + + def test_verify_returns_warning_for_security_files(self): + result = self.verifier.verify(["src/auth/login.ts"]) + self.assertIsNotNone(result) + self.assertIn("security", result.lower()) + + def test_verify_returns_none_for_unrelated_files(self): + result = self.verifier.verify(["README.md"]) + self.assertIsNone(result) + + def test_verify_with_empty_list_returns_none(self): + result = self.verifier.verify([]) + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main()