#!/usr/bin/env python3 """Scan Nexa BaZi files for mixed interface and prompt-variable names.""" from __future__ import annotations import argparse import os import re import sys from dataclasses import dataclass from pathlib import Path SKILL_DIR = Path(__file__).resolve().parents[1] DEFAULT_RULES = SKILL_DIR / "references" / "nexa-forbidden-names.md" SEVERITY_ORDER = {"review": 0, "warn": 1, "error": 2} EXCLUDE_DIRS = { ".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__", "node_modules", "vendor", "dist", "build", "coverage", ".next", ".nuxt", ".venv", "venv", } TEXT_EXTENSIONS = { ".c", ".cc", ".cpp", ".cs", ".css", ".csv", ".go", ".h", ".hpp", ".html", ".java", ".js", ".json", ".jsx", ".kt", ".lua", ".md", ".mjs", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql", ".svelte", ".swift", ".toml", ".ts", ".tsx", ".txt", ".vue", ".xml", ".yaml", ".yml", } @dataclass(frozen=True) class Rule: severity: str current: str preferred: str concept: str reason: str @dataclass(frozen=True) class Issue: path: Path line_no: int line: str rule: Rule def split_md_row(line: str) -> list[str]: stripped = line.strip() if not stripped.startswith("|") or not stripped.endswith("|"): return [] return [cell.strip().strip("`").strip() for cell in stripped.strip("|").split("|")] def parse_rules(path: Path) -> list[Rule]: header: list[str] | None = None rules: list[Rule] = [] for raw_line in path.read_text(encoding="utf-8").splitlines(): cells = split_md_row(raw_line) if not cells: continue if {"severity", "current", "preferred"}.issubset(set(cells)): header = cells continue if cells and all(set(cell) <= {"-"} for cell in cells): continue if not header or len(cells) < len(header): continue row = dict(zip(header, cells)) severity = row.get("severity", "").lower() if severity not in SEVERITY_ORDER: continue current = row.get("current", "").strip() preferred = row.get("preferred", "").strip() if not current or not preferred: continue rules.append( Rule( severity=severity, current=current, preferred=preferred, concept=row.get("concept", ""), reason=row.get("reason", ""), ) ) return rules def iter_files(paths: list[Path]) -> list[Path]: found: list[Path] = [] for path in paths: if not path.exists(): print(f"warning: path does not exist: {path}", file=sys.stderr) continue if path.is_file(): if is_text_candidate(path): found.append(path) continue for root, dirs, files in os.walk(path): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] for name in files: candidate = Path(root) / name if is_text_candidate(candidate): found.append(candidate) return found def is_text_candidate(path: Path) -> bool: return path.suffix.lower() in TEXT_EXTENSIONS def identifier_pattern(name: str) -> re.Pattern[str] | None: if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name): return None return re.compile(r"(? bool: pattern = identifier_pattern(rule.current) if pattern: return bool(pattern.search(line)) return rule.current in line def scan(paths: list[Path], rules: list[Rule], min_severity: str) -> list[Issue]: min_rank = SEVERITY_ORDER[min_severity] active_rules = [rule for rule in rules if SEVERITY_ORDER[rule.severity] >= min_rank] issues: list[Issue] = [] for path in iter_files(paths): try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: try: text = path.read_text(encoding="utf-8-sig") except UnicodeDecodeError: continue for line_no, line in enumerate(text.splitlines(), start=1): for rule in active_rules: if line_has_rule(line, rule): issues.append(Issue(path=path, line_no=line_no, line=line.strip(), rule=rule)) return issues def print_rules(rules: list[Rule]) -> None: for rule in rules: print(f"{rule.severity}\t{rule.current}\t{rule.preferred}\t{rule.concept}") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("paths", nargs="*", type=Path, help="files or directories to scan") parser.add_argument("--rules", type=Path, default=DEFAULT_RULES, help="path to nexa-forbidden-names.md") parser.add_argument( "--min-severity", choices=sorted(SEVERITY_ORDER, key=SEVERITY_ORDER.get), default="review", help="minimum severity to report", ) parser.add_argument("--list-rules", action="store_true", help="print loaded rules and exit") parser.add_argument("--no-fail", action="store_true", help="always exit 0") args = parser.parse_args() if not args.rules.exists(): print(f"rules file not found: {args.rules}", file=sys.stderr) return 2 rules = parse_rules(args.rules) if args.list_rules: print_rules(rules) return 0 if not args.paths: parser.error("provide at least one file or directory") issues = scan(args.paths, rules, args.min_severity) if not issues: print("No Nexa BaZi contract naming issues found.") return 0 for issue in issues: rule = issue.rule print(f"{issue.path}:{issue.line_no}: {rule.current} -> {rule.preferred} [{rule.severity}]") print(f" concept: {rule.concept}") print(f" reason: {rule.reason}") print(f" line: {issue.line}") counts: dict[str, int] = {"error": 0, "warn": 0, "review": 0} for issue in issues: counts[issue.rule.severity] += 1 print(f"\n{len(issues)} issue(s) found: {counts['error']} error, {counts['warn']} warn, {counts['review']} review.") blocking = any(issue.rule.severity in {"error", "warn"} for issue in issues) return 0 if args.no_fail or not blocking else 1 if __name__ == "__main__": raise SystemExit(main())