#!/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())