#!/usr/bin/env python3 """Fix-marker regression guard for RuView. Reads ``scripts/fix-markers.json`` and asserts that every previously-shipped fix is still present in the codebase: * every file listed in a marker must exist; * every ``require`` pattern must appear in at least one of the marker's files (a missing pattern means the fix was probably reverted); * no ``forbid`` pattern may appear in any of the marker's files (a re-appearing anti-pattern means the bug was re-introduced). A pattern is a literal substring by default. Wrap it in ``/.../`` to treat it as a (multiline, case-sensitive) regular expression, e.g. ``"/fall_thresh\\s*=\\s*2\\.0/"``. This is a stdlib-only script — no dependencies, runs anywhere Python 3.8+ does. Usage:: python scripts/check_fix_markers.py # check everything (CI) python scripts/check_fix_markers.py --list # list all markers python scripts/check_fix_markers.py --json # machine-readable result python scripts/check_fix_markers.py --only RuView#396 RuView#521 Exit codes: 0 = all markers OK, 1 = one or more regressions, 2 = bad manifest. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent MANIFEST_PATH = REPO_ROOT / "scripts" / "fix-markers.json" # Best-effort UTF-8 stdout (Windows consoles default to cp1252); harmless on # Linux/CI where it's already UTF-8. We still keep all symbols ASCII below so # the script works even if reconfigure() is unavailable. try: # pragma: no cover - environment-dependent sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # ANSI colours — disabled automatically when stdout isn't a TTY (CI logs are # plain either way, but keep them readable locally). _TTY = sys.stdout.isatty() def _c(code: str, s: str) -> str: return f"\033[{code}m{s}\033[0m" if _TTY else s GREEN = lambda s: _c("32", s) RED = lambda s: _c("31", s) YELLOW = lambda s: _c("33", s) DIM = lambda s: _c("2", s) BOLD = lambda s: _c("1", s) OK_MARK = "PASS" BAD_MARK = "FAIL" ARROW = "->" class ManifestError(Exception): pass def load_manifest() -> dict: if not MANIFEST_PATH.exists(): raise ManifestError(f"manifest not found: {MANIFEST_PATH}") try: data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) except json.JSONDecodeError as e: raise ManifestError(f"manifest is not valid JSON: {e}") from e if not isinstance(data, dict) or not isinstance(data.get("markers"), list): raise ManifestError("manifest must be an object with a 'markers' array") ids = [m.get("id") for m in data["markers"]] dupes = {i for i in ids if ids.count(i) > 1} if dupes: raise ManifestError(f"duplicate marker ids: {sorted(dupes)}") return data def _pattern_found(text: str, pattern: str) -> bool: if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"): return re.search(pattern[1:-1], text, re.MULTILINE) is not None return pattern in text def check_marker(marker: dict) -> tuple[bool, list[str]]: """Return (ok, problems) for a single marker.""" problems: list[str] = [] files = marker.get("files", []) require = marker.get("require", []) forbid = marker.get("forbid", []) if not files: problems.append("marker lists no files") return False, problems contents: dict[str, str] = {} for rel in files: p = REPO_ROOT / rel if not p.exists(): problems.append(f"missing file: {rel}") continue try: contents[rel] = p.read_text(encoding="utf-8", errors="replace") except OSError as e: problems.append(f"cannot read {rel}: {e}") haystack = "\n".join(contents.values()) for pat in require: if not _pattern_found(haystack, pat): problems.append(f"required marker absent (fix likely reverted): {pat!r}") for pat in forbid: for rel, text in contents.items(): if _pattern_found(text, pat): problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}") return (len(problems) == 0), problems def cmd_list(manifest: dict) -> int: print(BOLD(f"{len(manifest['markers'])} fix markers tracked:\n")) for m in manifest["markers"]: print(f" {BOLD(m['id']):<28} {m.get('title', '')}") if m.get("ref"): print(DIM(f" {m['ref']}")) for f in m.get("files", []): print(DIM(f" - {f}")) return 0 def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--list", action="store_true", help="list all markers and exit") ap.add_argument("--json", action="store_true", help="emit a JSON result object") ap.add_argument("--only", nargs="+", metavar="ID", help="only check the given marker ids") args = ap.parse_args(argv) try: manifest = load_manifest() except ManifestError as e: print(RED(f"[manifest error] {e}"), file=sys.stderr) return 2 if args.list: return cmd_list(manifest) markers = manifest["markers"] if args.only: wanted = set(args.only) markers = [m for m in markers if m["id"] in wanted] unknown = wanted - {m["id"] for m in markers} if unknown: print(RED(f"[error] unknown marker id(s): {sorted(unknown)}"), file=sys.stderr) return 2 results = [] failed = 0 for m in markers: ok, problems = check_marker(m) results.append({"id": m["id"], "title": m.get("title", ""), "ok": ok, "problems": problems}) if not ok: failed += 1 if args.json: print(json.dumps({"ok": failed == 0, "checked": len(markers), "failed": failed, "markers": results}, indent=2)) return 0 if failed == 0 else 1 print(BOLD(f"Fix-marker regression guard - {len(markers)} marker(s)\n")) for r in results: if r["ok"]: print(f" {GREEN('[' + OK_MARK + ']')} {r['id']:<28} {DIM(r['title'])}") else: print(f" {RED('[' + BAD_MARK + ']')} {BOLD(r['id']):<28} {r['title']}") for p in r["problems"]: print(f" {RED(ARROW)} {p}") print() if failed: print(RED(BOLD(f"{failed}/{len(markers)} marker(s) regressed."))) print(DIM(" A reverted fix is a regression. Restore the marker, or - if the change is")) print(DIM(" intentional - update scripts/fix-markers.json in the same PR with a rationale.")) return 1 print(GREEN(BOLD(f"All {len(markers)} fix markers present."))) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))