#!/usr/bin/env python3 """Scan source files for suspicious BaZi terminology translations.""" 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_TERMS = SKILL_DIR / "references" / "terms.md" 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 BadTerm: bad: str preferred: str zh: str standard_en: str reason: str @property def normalized(self) -> str: return normalize_identifier(self.bad) 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 normalize_cell(value: str) -> str: value = re.sub(r"", ",", value, flags=re.IGNORECASE) value = value.replace(",", ",").replace("、", ",").replace(";", ",").replace(";", ",") return value.strip() def normalize_identifier(value: str) -> str: return re.sub(r"[^a-z0-9]+", "", value.lower()) def parse_bad_terms(terms_path: Path) -> list[BadTerm]: rows: list[BadTerm] = [] header: list[str] | None = None for raw_line in terms_path.read_text(encoding="utf-8").splitlines(): cells = split_md_row(raw_line) if not cells: continue if "简体" in cells and "代码名" in cells and "避免" in 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)) preferred = row.get("代码名", "").strip("` ") avoid = normalize_cell(row.get("避免", "")) if not preferred or not avoid or avoid == "-": continue for item in [part.strip("` ") for part in avoid.split(",")]: if not item or item == "-": continue rows.append( BadTerm( bad=item, preferred=preferred, zh=row.get("简体", ""), standard_en=row.get("标准英文", ""), reason=f"{row.get('简体', '')} should use {preferred}", ) ) return rows def parse_approved_names(terms_path: Path) -> list[str]: names: set[str] = set() header: list[str] | None = None for raw_line in terms_path.read_text(encoding="utf-8").splitlines(): cells = split_md_row(raw_line) if not cells: continue if "简体" in cells and "代码名" in 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)) code_name = row.get("代码名", "").strip("` ") if code_name and code_name != "-": names.add(code_name) return sorted(names, key=str.lower) 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: if path.name.startswith(".") and path.suffix == "": return False return path.suffix.lower() in TEXT_EXTENSIONS def line_has_bad_term(line: str, bad_term: BadTerm) -> bool: bad = bad_term.bad lower = line.lower() identifier_like_bad_term = re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", bad) is not None pascal_case_bad_term = bad[:1].isupper() and identifier_like_bad_term if " " in bad: words = [re.escape(part) for part in bad.lower().split()] if re.search(r"\b" + r"\s+".join(words) + r"\b", lower): return True if identifier_like_bad_term: bad_norm = bad_term.normalized for identifier in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", line): if pascal_case_bad_term: if identifier == bad: return True continue if identifier[:1].isupper() and bad[:1].islower(): continue if normalize_identifier(identifier) == bad_norm: return True return False if pascal_case_bad_term: if re.search(r"\b" + re.escape(bad) + r"\b", line): return True elif re.search(r"\b" + re.escape(bad.lower()) + r"\b", lower): return True return False def scan(paths: list[Path], bad_terms: list[BadTerm]) -> list[tuple[Path, int, str, BadTerm]]: issues: list[tuple[Path, int, str, BadTerm]] = [] 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 bad_term in bad_terms: if line_has_bad_term(line, bad_term): issues.append((path, line_no, line.strip(), bad_term)) return issues def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("paths", nargs="*", type=Path, help="files or directories to scan") parser.add_argument("--terms", type=Path, default=DEFAULT_TERMS, help="path to terms.md") parser.add_argument("--no-fail", action="store_true", help="always exit 0") parser.add_argument("--list-approved", action="store_true", help="print approved code names and exit") args = parser.parse_args() if not args.terms.exists(): print(f"terms file not found: {args.terms}", file=sys.stderr) return 2 if args.list_approved: for name in parse_approved_names(args.terms): print(name) return 0 if not args.paths: parser.error("provide at least one file or directory") bad_terms = parse_bad_terms(args.terms) issues = scan(args.paths, bad_terms) if not issues: print("No suspicious BaZi terminology names found.") return 0 for path, line_no, line, bad_term in issues: print(f"{path}:{line_no}: {bad_term.bad} -> {bad_term.preferred}") print(f" zh: {bad_term.zh}; standard_en: {bad_term.standard_en}") print(f" line: {line}") print(f"\n{len(issues)} issue(s) found.") return 0 if args.no_fail else 1 if __name__ == "__main__": raise SystemExit(main())