diff --git a/.github/workflows/pr-scope-check.yml b/.github/workflows/pr-scope-check.yml new file mode 100644 index 000000000..fe2df7bc3 --- /dev/null +++ b/.github/workflows/pr-scope-check.yml @@ -0,0 +1,91 @@ +name: PR Scope Check + +permissions: + contents: read + pull-requests: read + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + scope-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get full history for proper diff + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + + - name: Run PR Scope Check + run: | + python scripts/check_pr_scope.py --strict + continue-on-error: false + + - name: Check branch naming (secure) + env: + BRANCH_NAME: ${{ github.head_ref }} + run: | + # Validate branch name using environment variable (safer than direct interpolation) + if [[ ! "$BRANCH_NAME" =~ ^(feat|fix|chore|refactor|docs|test)/[a-z]+(-[a-z]+)*$ ]]; then + echo "❌ Branch name must follow pattern: type/short-description" + echo " Current: $BRANCH_NAME" + echo " Examples: feat/add-user-auth, fix/validate-input, chore/update-deps" + exit 1 + fi + echo "✅ Branch name follows convention: $BRANCH_NAME" + + - name: Check PR size limits (secure) + env: + BASE_BRANCH: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + run: | + # Validate input parameters + if [[ -z "$BASE_BRANCH" || -z "$HEAD_REF" ]]; then + echo "❌ Missing required branch information" + exit 1 + fi + + # Ensure base branch exists in origin + if ! git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then + echo "❌ Base branch origin/$BASE_BRANCH not found" + exit 1 + fi + + # Count files changed using triple-dot merge-base diff for accurate PR changes + FILES_CHANGED=$(git diff --name-only "origin/$BASE_BRANCH...HEAD" | wc -l) + echo "Files changed: $FILES_CHANGED" + + # Count lines changed using triple-dot merge-base diff for accurate PR changes + SHORTSTAT=$(git diff --shortstat "origin/$BASE_BRANCH...HEAD") + LINES_CHANGED=0 + + if [[ -n "$SHORTSTAT" ]]; then + # Extract insertions and deletions from shortstat + INSERTIONS=$(echo "$SHORTSTAT" | grep -o '[0-9]\+ insertion' | head -1 | grep -o '[0-9]\+' || echo "0") + DELETIONS=$(echo "$SHORTSTAT" | grep -o '[0-9]\+ deletion' | head -1 | grep -o '[0-9]\+' || echo "0") + LINES_CHANGED=$((INSERTIONS + DELETIONS)) + fi + + echo "Lines changed: $LINES_CHANGED" + + # Check limits with proper error handling + if [[ "$FILES_CHANGED" -gt 50 ]]; then + echo "❌ Too many files changed: $FILES_CHANGED (max 50)" + exit 1 + fi + + if [[ "$LINES_CHANGED" -gt 1500 ]]; then + echo "❌ Too many lines changed: $LINES_CHANGED (max 1500)" + exit 1 + fi + + echo "✅ PR size within limits: $FILES_CHANGED files, $LINES_CHANGED lines" diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..c0aabd130 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# SAMO-DL Makefile - Minimal Code Quality Edition +.PHONY: help format lint test quality-check clean check-scope pre-commit-install + +help: ## Show this help message + @echo "SAMO-DL Code Quality Commands" + @echo "=============================" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +format: ## Format code with ruff + ruff format src/ tests/ scripts/ + +lint: ## Run comprehensive linting with ruff and pylint + ruff check src/ tests/ scripts/ + pylint --rcfile=pyproject.toml src/ tests/ scripts/ + +test: ## Run tests with coverage + pytest tests/ --cov=src --cov-report=term-missing + +quality-check: ## Run all quality checks (matches pre-commit) + @echo "Running comprehensive code quality checks..." + ruff check --diff src/ tests/ scripts/ + ruff format --check src/ tests/ scripts/ + pylint --rcfile=pyproject.toml src/ tests/ scripts/ + mypy src/ tests/ scripts/ + bandit -r src/ -c pyproject.toml + bandit -r tests/ -c pyproject.toml -s B101 + safety scan --json --output safety-report.json + docformatter --check --wrap-summaries=88 --wrap-descriptions=88 src/ tests/ scripts/ + +check-scope: ## Check PR scope compliance + python scripts/check_pr_scope.py --strict + +pre-commit-install: ## Install pre-commit hooks + pre-commit install + git config commit.template .gitmessage.txt + +clean: ## Clean up generated files + rm -rf build/ dist/ *.egg-info/ + rm -rf .pytest_cache/ .coverage htmlcov/ + rm -rf bandit-report.json safety-report.json + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete diff --git a/scripts/check_pr_scope.py b/scripts/check_pr_scope.py new file mode 100644 index 000000000..848e4d112 --- /dev/null +++ b/scripts/check_pr_scope.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""PR Scope Checker - Prevents Monster PRs. + +This script validates that pull requests stay within scope limits: +- Max 50 files changed +- Max 1500 lines changed +- Single purpose (one concern per PR) +- No mixing of concerns (API + tests + docs, etc.) + +Usage: + python scripts/check_pr_scope.py [--branch ] [--strict] +""" + +import argparse +import os +import re +import subprocess +import sys +from typing import Dict, List, Optional, Set, Tuple + +try: + from pr_scope_config import ( + ACCEPTABLE_COMBINATIONS, + BRANCH_NAME_PATTERN, + FILE_TYPE_PATTERNS, + MAX_FILES_CHANGED, + MAX_FILES_TO_DISPLAY, + MAX_FILE_TYPES_FOR_MIXED_CONCERNS, + MAX_FILE_TYPES_FOR_WARNING, + MAX_LINES_CHANGED, + MIXING_INDICATORS, + SINGLE_PURPOSE_KEYWORDS, + ) +except ImportError: + # Fallback for when running from different directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, script_dir) + from pr_scope_config import ( + ACCEPTABLE_COMBINATIONS, + BRANCH_NAME_PATTERN, + FILE_TYPE_PATTERNS, + MAX_FILES_CHANGED, + MAX_FILES_TO_DISPLAY, + MAX_FILE_TYPES_FOR_MIXED_CONCERNS, + MAX_FILE_TYPES_FOR_WARNING, + MAX_LINES_CHANGED, + MIXING_INDICATORS, + SINGLE_PURPOSE_KEYWORDS, + ) + + +def run_command(cmd: List[str]) -> Tuple[str, str, int]: + """Run command and return (stdout, stderr, returncode). + + Security Note: This function is safe because: + 1. All commands are hardcoded in the script (git commands only) + 2. No user input is directly passed to subprocess + 3. Commands are passed as a list of strings, not shell strings + 4. All git commands used are read-only operations + + Args: + cmd: List of command arguments (e.g., ["git", "diff", "--name-only"]) + + Returns: + Tuple of (stdout, stderr, returncode) + """ + # Validate that we're only running git commands for security + if not cmd or cmd[0] != "git": + raise ValueError( + f"Only git commands are allowed, got: {cmd[0] if cmd else 'empty'}" + ) + + # Additional validation: only allow specific git subcommands + allowed_git_commands = { + "diff", "log", "rev-list", "branch", "show", "symbolic-ref" + } + if len(cmd) > 1 and cmd[1] not in allowed_git_commands: + raise ValueError( + f"Git subcommand '{cmd[1]}' not allowed. " + f"Allowed: {allowed_git_commands}" + ) + + # Security: cmd is validated above and contains only hardcoded git commands + # No user input reaches this point, preventing command injection + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + return result.stdout.strip(), result.stderr.strip(), result.returncode + + +def get_git_stats( + base: str = "HEAD~1", + head: str = "HEAD", +) -> Tuple[int, int, List[str]]: + """Get git statistics for a commit range.""" + # Use git range operator base...head for both name-only and stats + range_spec = f"{base}...{head}" + + # Get number of files changed + stdout, stderr, code = run_command(["git", "diff", "--name-only", range_spec]) + if code != 0: + print(f"❌ Git diff error: {stderr}") + return 0, 0, [] + + files = stdout.split("\n") if stdout else [] + num_files = len([f for f in files if f.strip()]) + + # Get lines changed using shortstat + stdout, stderr, code = run_command(["git", "diff", "--shortstat", range_spec]) + lines_changed = 0 + if code != 0: + print(f"❌ Git diff --shortstat error: {stderr}") + elif stdout: + # Parse shortstat format like "1 file changed, 10 insertions(+), 2 deletions(-)" + shortstat = stdout.strip() + if shortstat: + insertions = 0 + deletions = 0 + ins_match = re.search(r"(\d+)\s+insertions?\(\+\)", shortstat) + del_match = re.search(r"(\d+)\s+deletions?\(-\)", shortstat) + if ins_match: + insertions = int(ins_match.group(1)) + if del_match: + deletions = int(del_match.group(1)) + lines_changed = insertions + deletions + + return num_files, lines_changed, files + + +def _determine_commit_range(base: Optional[str], head: Optional[str]) -> str: + """Determine the commit range to check.""" + if base and head: + return f"{base}..{head}" + + # Try to determine range from environment or git context + # Check for common CI environment variables + if os.environ.get("GITHUB_BASE_REF") and os.environ.get("GITHUB_HEAD_REF"): + base_ref = os.environ["GITHUB_BASE_REF"] + # Use local HEAD for the PR tip to handle forked PRs + return f"origin/{base_ref}..HEAD" + + # Fallback to HEAD^..HEAD for single commit + return "HEAD^..HEAD" + + +def _get_commit_shas(commit_range: str) -> List[str]: + """Get all non-merge commit SHAs in the range.""" + stdout, stderr, code = run_command(["git", "rev-list", "--no-merges", commit_range]) + if code != 0: + print(f"❌ Git rev-list error: {stderr}") + return [] + + return [sha.strip() for sha in stdout.split("\n") if sha.strip()] + + +def _check_single_commit(sha: str) -> bool: + """Check a single commit message for quality.""" + # Get the oneline commit message + stdout, stderr, code = run_command(["git", "log", "-1", "--format=%s", sha]) + if code != 0: + print(f"❌ Failed to get commit message for {sha}: {stderr}") + return False + + commit_msg = stdout.strip() + if not commit_msg: + print(f"❌ Empty commit message for {sha}") + return False + + print(f"🔎 Checking commit {sha[:8]}: {commit_msg}") + + # Check for single-purpose keywords + has_single_purpose = any( + commit_msg.startswith(keyword) for keyword in SINGLE_PURPOSE_KEYWORDS + ) + + if not has_single_purpose: + print( + f"❌ Commit {sha[:8]} message must start with " + f"feat:, fix:, chore:, refactor:, docs:, or test:", + ) + print(f" Message: {commit_msg}") + return False + + # Check for mixing concerns (contains 'and', 'also', 'plus') + if any(indicator in commit_msg.lower() for indicator in MIXING_INDICATORS): + print( + f"❌ Commit {sha[:8]} message indicates multiple concerns " + f"(contains 'and', 'also', etc.)", + ) + print(f" Message: {commit_msg}") + return False + + print(f"✅ Commit {sha[:8]} passed quality checks") + return True + + +def check_commit_message_quality( + base: Optional[str] = None, + head: Optional[str] = None, +) -> bool: + """Check if commit messages follow single-purpose rules for all commits in range.""" + commit_range = _determine_commit_range(base, head) + print(f"🔍 Checking commit messages in range: {commit_range}") + + commit_shas = _get_commit_shas(commit_range) + if not commit_shas: + print("ℹ️ No non-merge commits found in range") + return True + + print(f"📝 Found {len(commit_shas)} commit(s) to check") + + return all(_check_single_commit(sha) for sha in commit_shas) + + +def check_branch_name_quality() -> bool: + """Check if branch name follows naming conventions.""" + # First check for GitHub CI environment variable + branch_name = os.environ.get("GITHUB_HEAD_REF", "").strip() + + if not branch_name: + # Try git symbolic-ref as fallback for detached HEAD + stdout, stderr, code = run_command(["git", "symbolic-ref", "--short", "HEAD"]) + if code == 0 and stdout.strip(): + branch_name = stdout.strip() + + if not branch_name: + # Fallback to git branch --show-current + stdout, stderr, code = run_command(["git", "branch", "--show-current"]) + if code != 0: + print(f"❌ Git branch error: {stderr}") + return False + branch_name = stdout.strip() + + if not branch_name: + print("❌ Could not determine current branch (detached HEAD in CI)") + print(" This may occur in CI environments. Consider setting GITHUB_HEAD_REF") + return False + + # Check naming pattern: type/short-description + if not re.match(BRANCH_NAME_PATTERN, branch_name): + print("❌ Branch name must follow pattern: type/short-description") + print(f" Current: {branch_name}") + print(" Examples: feat/add-user-auth, fix/validate-input, chore/update-deps") + return False + + return True + + +def _check_extensions( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches extension patterns.""" + return "extensions" in patterns and any(file_path.endswith(ext) for ext in patterns["extensions"]) + + +def _check_directories( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches directory patterns.""" + return "directories" in patterns and any(file_path.startswith(dir_path) for dir_path in patterns["directories"]) + + +def _check_suffixes( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches suffix patterns.""" + return "suffixes" in patterns and any(file_path.endswith(suffix) for suffix in patterns["suffixes"]) + + +def _check_prefixes( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches prefix patterns.""" + return "prefixes" in patterns and any(file_path.startswith(prefix) for prefix in patterns["prefixes"]) + + +def _check_patterns_regex( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches regex patterns.""" + if "patterns" not in patterns: + return False + return any( + re.search(pattern, file_path, re.IGNORECASE) + for pattern in patterns["patterns"] + ) + + +def _check_exact_files( + file_path: str, patterns: Dict[str, List[str]] +) -> bool: + """Check if file matches exact file patterns.""" + return "exact_files" in patterns and file_path in patterns["exact_files"] + + +def _check_keywords(patterns: Dict[str, List[str]], file_lower: str) -> bool: + """Check if file matches keyword patterns.""" + if "keywords" not in patterns: + return False + + # Check for exact keyword matches + for keyword in patterns["keywords"]: + if keyword in file_lower: + return True + + # Check for regex patterns if they exist + if "patterns" in patterns: + for pattern in patterns["patterns"]: + if re.search(pattern, file_lower): + return True + + return False + + +def _check_patterns( + file_path: str, patterns: Dict[str, List[str]], file_lower: str +) -> bool: + """Check if file matches any pattern in the given patterns dict.""" + return ( + _check_extensions(file_path, patterns) or + _check_directories(file_path, patterns) or + _check_suffixes(file_path, patterns) or + _check_prefixes(file_path, patterns) or + _check_patterns_regex(file_path, patterns) or + _check_exact_files(file_path, patterns) or + _check_keywords(patterns, file_lower) + ) + + +def detect_file_types(file_path: str) -> Set[str]: + """Detect all applicable file types for a given file path.""" + file_lower = file_path.lower() + detected_types = set() + + # First, check extension-based categories (mutually exclusive) + if file_path.endswith((".py", ".js", ".ts", ".java", ".cpp", ".c", ".h")): + detected_types.add("code") + elif file_path.endswith((".md", ".rst", ".txt", ".adoc")): + detected_types.add("docs") + + # Then check other categories (not mutually exclusive) + for file_type, patterns in FILE_TYPE_PATTERNS.items(): + # Skip already handled extension-based categories + if file_type in ["code", "docs"]: + continue + if _check_patterns(file_path, patterns, file_lower): + detected_types.add(file_type) + + return detected_types + + +def check_mixed_concerns(files: List[str]) -> Tuple[bool, Set[str]]: + """Check for mixed concerns in changed files.""" + if not files: + return False, set() + + file_types = set() + for file in files: + detected_types = detect_file_types(file) + file_types.update(detected_types) + + # Flag as mixed concerns only if we have more than + # MAX_FILE_TYPES_FOR_MIXED_CONCERNS types OR if we have an unusual + # combination that's not in acceptable list + is_mixed_concerns = ( + len(file_types) > MAX_FILE_TYPES_FOR_MIXED_CONCERNS or + (len(file_types) > MAX_FILE_TYPES_FOR_WARNING and + file_types not in ACCEPTABLE_COMBINATIONS) + ) + + return is_mixed_concerns, file_types + + +def check_size_limits(num_files: int, lines_changed: int, strict_mode: bool) -> bool: + """Check if PR size is within limits.""" + all_passed = True + + if num_files > MAX_FILES_CHANGED: + print( + f"❌ Too many files changed! Max {MAX_FILES_CHANGED} allowed, " + f"got {num_files}" + ) + if strict_mode: + all_passed = False + + if lines_changed > MAX_LINES_CHANGED: + print( + f"❌ Too many lines changed! Max {MAX_LINES_CHANGED} allowed, " + f"got {lines_changed}" + ) + if strict_mode: + all_passed = False + + return all_passed + + +def display_changed_files(files: List[str]) -> None: + """Display list of changed files.""" + if not files: + return + + print("\n📁 Files changed:") + for file in files[:MAX_FILES_TO_DISPLAY]: # Show first MAX_FILES_TO_DISPLAY + if file.strip(): + print(f" - {file}") + if len(files) > MAX_FILES_TO_DISPLAY: + print(f" ... and {len(files) - MAX_FILES_TO_DISPLAY} more") + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Check PR scope compliance") + parser.add_argument( + "--branch", + default="HEAD", + help="Branch to check (default: HEAD)", + ) + parser.add_argument( + "--base", + help="Base branch/commit for range comparison (e.g., main, origin/main)", + ) + parser.add_argument( + "--head", + help="Head branch/commit for range comparison (default: current branch)", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Strict mode - fail on any warning", + ) + return parser.parse_args() + + +def _check_branch_and_commits(args) -> bool: + """Check branch name and commit messages.""" + print("📋 Checking branch name...") + branch_ok = check_branch_name_quality() + + print("\n📝 Checking commit message...") + commit_ok = check_commit_message_quality(args.base, args.head) + + return branch_ok and commit_ok + + +def _get_git_stats_for_check(args) -> Tuple[int, int, List[str]]: + """Get git statistics for the given arguments.""" + print("\n📊 Checking size limits...") + # Determine base for git stats - use full range for multi-commit PRs + base_for_stats = args.base or args.branch + head_for_stats = args.head or "HEAD" + num_files, lines_changed, files = get_git_stats(base_for_stats, head_for_stats) + + print(f" Files changed: {num_files}") + print(f" Lines changed: {lines_changed}") + + return num_files, lines_changed, files + + +def _check_mixed_concerns_display(files: List[str], strict_mode: bool) -> bool: + """Check and display mixed concerns information.""" + print("\n🎯 Checking for mixed concerns...") + if not files: + return True + + is_mixed_concerns, file_types = check_mixed_concerns(files) + + if is_mixed_concerns: + print(f"⚠️ Mixed concerns detected: {', '.join(sorted(file_types))}") + print(" Consider splitting into separate PRs") + print( + " Acceptable combinations include: code+tests, code+tests+config, etc.", + ) + return not strict_mode + + if len(file_types) > MAX_FILE_TYPES_FOR_WARNING: + # Show info about acceptable combinations but don't fail + print(f"ℹ️ Multiple file types detected: {', '.join(sorted(file_types))}") + print(" This combination is acceptable for a single PR") + + return True + + +def _check_size_and_concerns(args) -> bool: + """Check file size limits and mixed concerns.""" + num_files, lines_changed, files = _get_git_stats_for_check(args) + + # Check size limits + size_ok = check_size_limits(num_files, lines_changed, args.strict) + + # Display changed files + display_changed_files(files) + + # Check for mixed concerns + concerns_ok = _check_mixed_concerns_display(files, args.strict) + + return size_ok and concerns_ok + + +def run_scope_checks(args) -> bool: + """Run all scope checks and return overall result.""" + branch_commit_ok = _check_branch_and_commits(args) + size_concerns_ok = _check_size_and_concerns(args) + + return branch_commit_ok and size_concerns_ok + + +def print_final_result(all_passed: bool) -> int: + """Print final result and return exit code.""" + print("\n" + "=" * 50) + if all_passed: + print("✅ PR Scope Check PASSED") + return 0 + print("❌ PR Scope Check FAILED") + print("\n💡 Remember the rules:") + print(f" • Max {MAX_FILES_CHANGED} files changed") + print(f" • Max {MAX_LINES_CHANGED} lines changed") + print(" • ONE purpose per PR") + print(" • Single concern (no mixing API + tests + docs)") + return 1 + + +def main(): + """Main function.""" + print("🔍 Checking PR Scope Compliance") + print("=" * 50) + + args = parse_arguments() + all_passed = run_scope_checks(args) + return print_final_result(all_passed) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pr_scope_config.py b/scripts/pr_scope_config.py new file mode 100644 index 000000000..41b787be7 --- /dev/null +++ b/scripts/pr_scope_config.py @@ -0,0 +1,80 @@ +"""Configuration module for PR scope checking. + +This module contains all static configuration values used by the PR scope checker +to avoid duplication and make maintenance easier. +""" + +from typing import Dict, List, Set + +# File type detection patterns +FILE_TYPE_PATTERNS: Dict[str, Dict[str, List[str]]] = { + "code": { + "extensions": [".py", ".js", ".ts", ".java", ".cpp", ".c", ".h"], + }, + "docs": { + "extensions": [".md", ".rst", ".txt", ".adoc"], + }, + "tests": { + "directories": ["tests/", "test/"], + "suffixes": [ + "_test.py", "_test.js", "_test.ts", "_test.java", + "_test.cpp", "_test.c", "_test.h" + ], + "prefixes": ["test_"], + "patterns": [r"(^|/)(test_.*|.*_test\.(py|js|ts|java|cpp|c|h))$"], + }, + "config": { + "directories": ["config/"], + "extensions": [".yml", ".yaml", ".json", ".toml", ".cfg"], + "exact_files": [ + "config.py", "config.js", "config.ts", "config.java", + "config.cpp", "config.c", "config.h" + ], + }, + "docker": { + "keywords": ["docker", "Dockerfile"], + }, + "api": { + "keywords": ["api", "endpoint"], + }, + "ml": { + "keywords": ["model", "machine_learning", "ai"], + "patterns": [r"\bml\b"], # Use word boundary for "ml" to avoid matching "html" + }, +} + +# Acceptable file type combinations for single PRs +ACCEPTABLE_COMBINATIONS: List[Set[str]] = [ + {"code", "tests"}, # Feature + tests + {"code", "tests", "config"}, # Feature + tests + config + {"code", "tests", "docs"}, # Feature + tests + docs + {"code", "tests", "config", "docs"}, # Feature + tests + config + docs + {"code", "config"}, # Code changes with config + {"code", "docs"}, # Code changes with docs + {"config", "docs"}, # Config changes with docs + {"tests", "config"}, # Tests with config +] + +# Size limits +MAX_FILES_CHANGED = 50 +MAX_LINES_CHANGED = 1500 +MAX_FILE_TYPES_FOR_MIXED_CONCERNS = 4 +MAX_FILE_TYPES_FOR_WARNING = 2 + +# Branch naming pattern +BRANCH_NAME_PATTERN = r"^(feat|fix|chore|refactor|docs|test)/[a-z]+(?:-[a-z]+)*$" + +# Commit message patterns +SINGLE_PURPOSE_KEYWORDS = [ + "feat:", + "fix:", + "chore:", + "refactor:", + "docs:", + "test:", +] + +MIXING_INDICATORS = [" and ", " also ", " plus ", " & ", " in addition "] + +# Display limits +MAX_FILES_TO_DISPLAY = 10