Files
bazi_translation/scripts/check_nexa_bazi_contract.py
T
2026-06-03 22:24:07 +08:00

248 lines
6.5 KiB
Python
Executable File

#!/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"(?<![A-Za-z0-9_])" + re.escape(name) + r"(?![A-Za-z0-9_])")
def line_has_rule(line: str, rule: Rule) -> 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())