277 lines
7.7 KiB
Python
Executable File
277 lines
7.7 KiB
Python
Executable File
#!/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())
|