scripts/check_changes_filter.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
r"""Script to verify that all changed files in src/frontend are covered by patterns in changes-filter.yaml.
This ensures that CI workflows (especially Playwright tests) are triggered appropriately for frontend changes.
Usage:
# Check files changed in current branch vs main
git diff --name-only origin/main HEAD | python scripts/check_changes_filter.py
# Check specific files
echo -e "src/frontend/file1.tsx\nsrc/frontend/file2.ts" | python scripts/check_changes_filter.py
Note:
Only files under src/frontend/ are checked. All other files are ignored.
Exit codes:
0 - All frontend files are covered by patterns
1 - Some frontend files are not covered (or error occurred)
"""
import sys
from pathlib import Path
import yaml
def load_filter_patterns(filter_file: Path) -> dict[str, list[str]]:
"""Load all patterns from the changes-filter.yaml file.
Validates and normalizes the YAML structure to ensure it's a dict mapping
str to list[str]. Handles top-level "filters" key if present.
"""
with filter_file.open() as f:
data = yaml.safe_load(f)
# Handle empty or null file
if data is None:
return {}
# If there's a top-level "filters" key, use that instead
if isinstance(data, dict) and "filters" in data:
data = data["filters"]
# Ensure we have a dict
if not isinstance(data, dict):
msg = f"Expected dict at top level, got {type(data).__name__}"
raise TypeError(msg)
# Normalize and validate the structure
result: dict[str, list[str]] = {}
for key, value in data.items():
# Validate key is a string
if not isinstance(key, str):
msg = f"Expected string key, got {type(key).__name__}: {key}"
raise TypeError(msg)
# Coerce single string to list
normalized_value = [value] if isinstance(value, str) else value
# Validate value is a list
if not isinstance(normalized_value, list):
msg = f"Expected list for key '{key}', got {type(normalized_value).__name__}"
raise TypeError(msg)
# Validate all items in the list are strings
for i, item in enumerate(normalized_value):
if not isinstance(item, str):
msg = f"Expected string in list for key '{key}' at index {i}, got {type(item).__name__}"
raise TypeError(msg)
result[key] = normalized_value
return result
def get_changed_files_from_stdin() -> list[str]:
"""Get list of changed files from stdin (one per line), filtered to src/frontend only."""
files = []
for line in sys.stdin:
stripped = line.strip()
if stripped and stripped.startswith("src/frontend/"):
files.append(stripped)
return files
def matches_pattern(file_path: str, pattern: str) -> bool:
"""Check if a file matches a glob pattern using pathlib semantics.
Supports ** and a simple one-level {a,b} brace expansion.
"""
import re
from pathlib import PurePosixPath
# Normalize
file_path = file_path.lstrip("./").replace("\\", "/")
pattern = pattern.lstrip("./")
# Simple one-level brace expansion: foo.{ts,tsx} -> [foo.ts, foo.tsx]
patterns = [pattern]
m = re.search(r"\{([^{}]+)\}", pattern)
if m:
opts = [opt.strip() for opt in m.group(1).split(",")]
pre, post = pattern[: m.start()], pattern[m.end() :]
patterns = [f"{pre}{opt}{post}" for opt in opts]
# PurePosixPath.match() only does relative matching from the right
# For patterns with **, we need full path matching
for pat in patterns:
if "**" in pat:
# Use fnmatch-style matching for ** patterns
# Convert ** to match any depth
import fnmatch
regex_pattern = pat.replace("**", "*")
if fnmatch.fnmatch(file_path, regex_pattern):
return True
else:
# Use pathlib matching for non-** patterns
p = PurePosixPath(file_path)
if p.match(pat):
return True
return False
def check_file_coverage(changed_files: list[str], filter_patterns: dict[str, list[str]]) -> tuple[list[str], list[str]]:
"""Check which files are covered by at least one pattern.
Returns: (covered_files, uncovered_files)
"""
# Flatten all patterns from all categories
all_patterns = []
for category_patterns in filter_patterns.values():
all_patterns.extend(category_patterns)
covered = []
uncovered = []
for file_path in changed_files:
is_covered = False
for pattern in all_patterns:
if matches_pattern(file_path, pattern):
is_covered = True
break
if is_covered:
covered.append(file_path)
else:
uncovered.append(file_path)
return covered, uncovered
def main():
"""Main execution function."""
# Get repository root
repo_root = Path(__file__).parent.parent
filter_file = repo_root / ".github" / "changes-filter.yaml"
if not filter_file.exists():
print(f"Error: Filter file not found at {filter_file}")
sys.exit(1)
# Load filter patterns
filter_patterns = load_filter_patterns(filter_file)
# Get changed files from stdin
changed_files = get_changed_files_from_stdin()
if not changed_files:
print("No changed files detected.")
return
print(f"Checking {len(changed_files)} changed file(s) against filter patterns...")
print()
# Check coverage
covered, uncovered = check_file_coverage(changed_files, filter_patterns)
# Report results
if uncovered:
print("❌ FAILURE: The following files are NOT covered by any pattern in changes-filter.yaml:")
print()
for file_path in sorted(uncovered):
print(f" - {file_path}")
print()
print(f"Total: {len(uncovered)} uncovered file(s) out of {len(changed_files)}")
print()
print("Please update .github/changes-filter.yaml to include patterns for these files.")
sys.exit(1)
else:
print("✅ SUCCESS: All changed files are covered by patterns in changes-filter.yaml")
print()
print(f"Checked {len(changed_files)} file(s):")
for file_path in sorted(covered):
print(f" ✓ {file_path}")
if __name__ == "__main__":
main()