Move Nexa contract checker to standalone skill
This commit is contained in:
@@ -1,247 +0,0 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user