248 lines
6.5 KiB
Python
Executable File
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())
|