- Added/updated .gitignore file - Fixed remote URL configuration - Updated project structure and files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
142 lines
4.0 KiB
Python
142 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Optional
|
|
import re
|
|
|
|
ALLOWED_PROJ = {
|
|
"CHORUS",
|
|
"COOEE",
|
|
"DHT",
|
|
"SHHH",
|
|
"KACHING",
|
|
"HMMM",
|
|
"UCXL",
|
|
"SLURP",
|
|
"RUSTLE",
|
|
"WHOOSH",
|
|
"BUBBLE",
|
|
}
|
|
ALLOWED_CAT = {"REQ", "INT", "SEC", "OBS", "PER", "COMP"}
|
|
|
|
REQ_PATTERN = re.compile(r"REQ:\s*([A-Z]+)-([A-Z]+)-(\d{3})")
|
|
UCXL_PATTERN = re.compile(r"UCXL:\s*ucxl://")
|
|
|
|
SKIP_DIRS = {
|
|
".git",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"venv",
|
|
"__pycache__",
|
|
".venv",
|
|
"target",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
path: Path
|
|
line_no: int
|
|
severity: str
|
|
message: str
|
|
line: str
|
|
|
|
|
|
def iter_files(paths: Iterable[Path]) -> Iterable[Path]:
|
|
for p in paths:
|
|
if p.is_dir():
|
|
for sub in p.rglob("*"):
|
|
if sub.is_dir():
|
|
if sub.name in SKIP_DIRS:
|
|
continue
|
|
continue
|
|
yield sub
|
|
elif p.is_file():
|
|
yield p
|
|
|
|
|
|
def validate_file(path: Path, require_ucxl: bool, max_distance: int) -> List[Finding]:
|
|
findings: List[Finding] = []
|
|
try:
|
|
text = path.read_text(errors="ignore")
|
|
except Exception as e:
|
|
findings.append(Finding(path, 0, "warn", f"unable to read file: {e}", line=""))
|
|
return findings
|
|
|
|
lines = text.splitlines()
|
|
for idx, line in enumerate(lines, start=1):
|
|
m = REQ_PATTERN.search(line)
|
|
if not m:
|
|
continue
|
|
proj, cat, _ = m.group(1), m.group(2), m.group(3)
|
|
if proj not in ALLOWED_PROJ:
|
|
findings.append(Finding(path, idx, "error", f"unknown PROJ '{proj}' in ID", line=line))
|
|
if cat not in ALLOWED_CAT:
|
|
findings.append(Finding(path, idx, "error", f"unknown CAT '{cat}' in ID", line=line))
|
|
if require_ucxl:
|
|
start = max(1, idx - max_distance)
|
|
end = min(len(lines), idx + max_distance)
|
|
window = lines[start - 1 : end]
|
|
if not any(UCXL_PATTERN.search(l) for l in window):
|
|
findings.append(
|
|
Finding(
|
|
path,
|
|
idx,
|
|
"error",
|
|
f"missing nearby UCXL backlink (±{max_distance} lines)",
|
|
line=line,
|
|
)
|
|
)
|
|
return findings
|
|
|
|
|
|
def cmd_check(args: argparse.Namespace) -> int:
|
|
paths = [Path(p) for p in args.paths]
|
|
all_findings: List[Finding] = []
|
|
for f in iter_files(paths):
|
|
try:
|
|
if f.stat().st_size > 3_000_000:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
all_findings.extend(
|
|
validate_file(f, require_ucxl=args.require_ucxl, max_distance=args.max_distance)
|
|
)
|
|
if all_findings:
|
|
for fd in all_findings:
|
|
print(f"{fd.path}:{fd.line_no}: {fd.severity}: {fd.message}")
|
|
if fd.line:
|
|
print(f" {fd.line.strip()}")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def build_argparser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(prog="speclint-check", description="Suite 2.0.0 traceability linter (WHOOSH local)")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
c = sub.add_parser("check", help="validate requirement IDs and UCXL backlinks")
|
|
c.add_argument("paths", nargs="+", help="files or directories to scan")
|
|
c.add_argument("--require-ucxl", action="store_true", help="require nearby UCXL backlink")
|
|
c.add_argument("--max-distance", type=int, default=5, help="line distance for UCXL proximity check")
|
|
c.set_defaults(func=cmd_check)
|
|
return p
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
try:
|
|
args = build_argparser().parse_args(argv)
|
|
return args.func(args)
|
|
except Exception as e:
|
|
print(f"speclint-check: internal error: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
|