Files
bazi_translation/scripts/check_bazi_names.py
T
2026-06-03 18:17:37 +08:00

277 lines
7.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"<br\s*/?>", ",", 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())