mirror of
https://github.com/github/gitignore.git
synced 2026-08-26 01:37:26 +02:00
feat: add .gitignore template validator script
Add a comprehensive template validation tool that checks for: - Valid glob pattern syntax - Duplicate pattern detection - Conflicting include/exclude rules - Coverage analysis with suggestions - CLI interface with argparse - Detailed reporting (errors, warnings, suggestions) - Support for processing all templates in the repo Signed-off-by: Srikanth Patchava <spatchava@meta.com>
This commit is contained in:
parent
7d476866ad
commit
46742213f6
433
scripts/validate_templates.py
Normal file
433
scripts/validate_templates.py
Normal file
@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gitignore Template Validator
|
||||
|
||||
Validates .gitignore template files for syntax correctness, duplicate patterns,
|
||||
conflicting rules, and provides coverage analysis with detailed reporting.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Severity(Enum):
|
||||
ERROR = "ERROR"
|
||||
WARNING = "WARNING"
|
||||
SUGGESTION = "SUGGESTION"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
severity: Severity
|
||||
line_number: int
|
||||
line_content: str
|
||||
message: str
|
||||
template_file: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
template_file: str
|
||||
total_lines: int = 0
|
||||
pattern_count: int = 0
|
||||
comment_count: int = 0
|
||||
blank_count: int = 0
|
||||
issues: List[Issue] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
return sum(1 for i in self.issues if i.severity == Severity.ERROR)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for i in self.issues if i.severity == Severity.WARNING)
|
||||
|
||||
@property
|
||||
def suggestion_count(self) -> int:
|
||||
return sum(1 for i in self.issues if i.severity == Severity.SUGGESTION)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return self.error_count > 0
|
||||
|
||||
|
||||
def is_valid_glob_pattern(pattern: str) -> bool:
|
||||
"""Check if a pattern is a valid glob/gitignore pattern."""
|
||||
clean = pattern.lstrip("!")
|
||||
if clean.startswith("/"):
|
||||
clean = clean[1:]
|
||||
if clean.endswith("/"):
|
||||
clean = clean[:-1]
|
||||
if not clean:
|
||||
return False
|
||||
try:
|
||||
# Check for unmatched brackets
|
||||
bracket_depth = 0
|
||||
i = 0
|
||||
while i < len(clean):
|
||||
ch = clean[i]
|
||||
if ch == "\\" and i + 1 < len(clean):
|
||||
i += 2
|
||||
continue
|
||||
if ch == "[":
|
||||
bracket_depth += 1
|
||||
elif ch == "]":
|
||||
if bracket_depth > 0:
|
||||
bracket_depth -= 1
|
||||
else:
|
||||
return False
|
||||
i += 1
|
||||
if bracket_depth != 0:
|
||||
return False
|
||||
# Test compile the pattern via fnmatch
|
||||
fnmatch.translate(clean)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_trailing_whitespace(line: str, line_number: int) -> Optional[Issue]:
|
||||
"""Detect trailing whitespace on a line."""
|
||||
if line != line.rstrip() and line.strip():
|
||||
return Issue(
|
||||
severity=Severity.WARNING,
|
||||
line_number=line_number,
|
||||
line_content=line,
|
||||
message="Line has trailing whitespace",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def find_duplicates(patterns: List[tuple]) -> List[Issue]:
|
||||
"""Find duplicate patterns in the list."""
|
||||
issues = []
|
||||
seen = {}
|
||||
for line_num, pattern in patterns:
|
||||
normalized = pattern.strip().rstrip("/")
|
||||
if normalized in seen:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity=Severity.WARNING,
|
||||
line_number=line_num,
|
||||
line_content=pattern,
|
||||
message=f"Duplicate pattern (first seen at line {seen[normalized]})",
|
||||
)
|
||||
)
|
||||
else:
|
||||
seen[normalized] = line_num
|
||||
return issues
|
||||
|
||||
|
||||
def find_conflicts(patterns: List[tuple]) -> List[Issue]:
|
||||
"""Find conflicting include/exclude rules for the same pattern."""
|
||||
issues = []
|
||||
include_patterns = {}
|
||||
exclude_patterns = {}
|
||||
|
||||
for line_num, pattern in patterns:
|
||||
stripped = pattern.strip()
|
||||
if stripped.startswith("!"):
|
||||
base = stripped[1:].strip().rstrip("/")
|
||||
exclude_patterns[base] = line_num
|
||||
if base in include_patterns:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity=Severity.WARNING,
|
||||
line_number=line_num,
|
||||
line_content=stripped,
|
||||
message=(
|
||||
f"Negation pattern conflicts with include at line "
|
||||
f"{include_patterns[base]}. Order matters in gitignore."
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
base = stripped.rstrip("/")
|
||||
include_patterns[base] = line_num
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def analyze_coverage(patterns: List[tuple]) -> List[Issue]:
|
||||
"""Analyze pattern coverage and suggest improvements."""
|
||||
issues = []
|
||||
wildcard_count = 0
|
||||
extension_patterns = []
|
||||
directory_patterns = []
|
||||
|
||||
for line_num, pattern in patterns:
|
||||
stripped = pattern.strip().lstrip("!")
|
||||
if stripped.startswith("*"):
|
||||
wildcard_count += 1
|
||||
if re.match(r"^\*\.[a-zA-Z0-9]+$", stripped):
|
||||
extension_patterns.append((line_num, stripped))
|
||||
if stripped.endswith("/"):
|
||||
directory_patterns.append((line_num, stripped))
|
||||
|
||||
if wildcard_count > 10:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity=Severity.SUGGESTION,
|
||||
line_number=0,
|
||||
line_content="",
|
||||
message=(
|
||||
f"Template has {wildcard_count} wildcard patterns. "
|
||||
"Consider grouping related patterns with comments."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
ext_groups = {}
|
||||
for line_num, pat in extension_patterns:
|
||||
ext = pat.split(".")[-1].lower()
|
||||
ext_groups.setdefault(ext, []).append(line_num)
|
||||
|
||||
for ext, lines in ext_groups.items():
|
||||
if len(lines) > 1:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity=Severity.SUGGESTION,
|
||||
line_number=lines[1],
|
||||
line_content="",
|
||||
message=(
|
||||
f"Multiple patterns target .{ext} files "
|
||||
f"(lines {', '.join(map(str, lines))}). Consider consolidating."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not directory_patterns and len(patterns) > 5:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity=Severity.SUGGESTION,
|
||||
line_number=0,
|
||||
line_content="",
|
||||
message=(
|
||||
"No directory-specific patterns found. "
|
||||
"Consider adding directory ignores (e.g., build/)."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_template(filepath: str) -> ValidationReport:
|
||||
"""Validate a single gitignore template file."""
|
||||
report = ValidationReport(template_file=filepath)
|
||||
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except OSError as e:
|
||||
report.issues.append(
|
||||
Issue(
|
||||
severity=Severity.ERROR,
|
||||
line_number=0,
|
||||
line_content="",
|
||||
message=f"Cannot read file: {e}",
|
||||
template_file=filepath,
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
||||
report.total_lines = len(lines)
|
||||
patterns = []
|
||||
|
||||
for i, raw_line in enumerate(lines, start=1):
|
||||
line = raw_line.rstrip("\n\r")
|
||||
|
||||
# Check trailing whitespace
|
||||
ws_issue = check_trailing_whitespace(line, i)
|
||||
if ws_issue:
|
||||
ws_issue.template_file = filepath
|
||||
report.issues.append(ws_issue)
|
||||
|
||||
stripped = line.strip()
|
||||
|
||||
# Blank line
|
||||
if not stripped:
|
||||
report.blank_count += 1
|
||||
continue
|
||||
|
||||
# Comment
|
||||
if stripped.startswith("#"):
|
||||
report.comment_count += 1
|
||||
continue
|
||||
|
||||
# Pattern line
|
||||
report.pattern_count += 1
|
||||
patterns.append((i, stripped))
|
||||
|
||||
# Validate glob syntax
|
||||
if not is_valid_glob_pattern(stripped):
|
||||
report.issues.append(
|
||||
Issue(
|
||||
severity=Severity.ERROR,
|
||||
line_number=i,
|
||||
line_content=stripped,
|
||||
message="Invalid glob pattern syntax",
|
||||
template_file=filepath,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for spaces in patterns (usually a mistake)
|
||||
if " " in stripped and not stripped.startswith("\\"):
|
||||
report.issues.append(
|
||||
Issue(
|
||||
severity=Severity.WARNING,
|
||||
line_number=i,
|
||||
line_content=stripped,
|
||||
message="Pattern contains spaces — this may be unintentional",
|
||||
template_file=filepath,
|
||||
)
|
||||
)
|
||||
|
||||
# Run duplicate detection
|
||||
dup_issues = find_duplicates(patterns)
|
||||
for issue in dup_issues:
|
||||
issue.template_file = filepath
|
||||
report.issues.extend(dup_issues)
|
||||
|
||||
# Run conflict detection
|
||||
conflict_issues = find_conflicts(patterns)
|
||||
for issue in conflict_issues:
|
||||
issue.template_file = filepath
|
||||
report.issues.extend(conflict_issues)
|
||||
|
||||
# Run coverage analysis
|
||||
coverage_issues = analyze_coverage(patterns)
|
||||
for issue in coverage_issues:
|
||||
issue.template_file = filepath
|
||||
report.issues.extend(coverage_issues)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def format_report(report: ValidationReport, verbose: bool = False) -> str:
|
||||
"""Format a validation report as human-readable text."""
|
||||
lines = []
|
||||
lines.append(f"\n{'='*60}")
|
||||
lines.append(f"File: {report.template_file}")
|
||||
lines.append(f"{'='*60}")
|
||||
lines.append(
|
||||
f" Lines: {report.total_lines} | Patterns: {report.pattern_count} "
|
||||
f"| Comments: {report.comment_count} | Blank: {report.blank_count}"
|
||||
)
|
||||
lines.append(
|
||||
f" Errors: {report.error_count} | Warnings: {report.warning_count} "
|
||||
f"| Suggestions: {report.suggestion_count}"
|
||||
)
|
||||
|
||||
if report.issues:
|
||||
lines.append("")
|
||||
for issue in sorted(report.issues, key=lambda x: (x.severity.value, x.line_number)):
|
||||
prefix = f" [{issue.severity.value}]"
|
||||
loc = f" Line {issue.line_number}:" if issue.line_number > 0 else ""
|
||||
lines.append(f"{prefix}{loc} {issue.message}")
|
||||
if verbose and issue.line_content:
|
||||
lines.append(f" > {issue.line_content}")
|
||||
else:
|
||||
lines.append(" No issues found. ✓")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def discover_templates(repo_root: str) -> List[str]:
|
||||
"""Discover all .gitignore template files in the repository."""
|
||||
templates = []
|
||||
for entry in os.listdir(repo_root):
|
||||
full_path = os.path.join(repo_root, entry)
|
||||
if os.path.isfile(full_path) and entry.endswith(".gitignore"):
|
||||
templates.append(full_path)
|
||||
# Also check Global/ and community/ subdirectories
|
||||
for subdir in ["Global", "community"]:
|
||||
subdir_path = os.path.join(repo_root, subdir)
|
||||
if os.path.isdir(subdir_path):
|
||||
for entry in os.listdir(subdir_path):
|
||||
full_path = os.path.join(subdir_path, entry)
|
||||
if os.path.isfile(full_path) and entry.endswith(".gitignore"):
|
||||
templates.append(full_path)
|
||||
return sorted(templates)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate .gitignore template files for common issues."
|
||||
)
|
||||
parser.add_argument(
|
||||
"files",
|
||||
nargs="*",
|
||||
help="Template files to validate. If none given, validates all in repo.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=".",
|
||||
help="Root directory of the gitignore repo (default: current directory).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show pattern content alongside issues.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--errors-only",
|
||||
action="store_true",
|
||||
help="Only report errors, not warnings or suggestions.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary",
|
||||
action="store_true",
|
||||
help="Print only a summary, not per-file details.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.files:
|
||||
templates = args.files
|
||||
else:
|
||||
templates = discover_templates(args.repo_root)
|
||||
|
||||
if not templates:
|
||||
print("No .gitignore templates found.")
|
||||
sys.exit(0)
|
||||
|
||||
reports = []
|
||||
for tpl in templates:
|
||||
report = validate_template(tpl)
|
||||
reports.append(report)
|
||||
|
||||
total_errors = 0
|
||||
total_warnings = 0
|
||||
total_suggestions = 0
|
||||
|
||||
for report in reports:
|
||||
if args.errors_only:
|
||||
report.issues = [i for i in report.issues if i.severity == Severity.ERROR]
|
||||
if not args.summary:
|
||||
print(format_report(report, verbose=args.verbose))
|
||||
total_errors += report.error_count
|
||||
total_warnings += report.warning_count
|
||||
total_suggestions += report.suggestion_count
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"SUMMARY: {len(reports)} templates validated")
|
||||
print(f" Total errors: {total_errors}")
|
||||
print(f" Total warnings: {total_warnings}")
|
||||
print(f" Total suggestions: {total_suggestions}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if total_errors > 0:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user