#!/usr/bin/env python3 """spec-revocation-check — generic spec-amendment revocation gate. Catches the two failure modes that have each felled a previous spec-amendment attempt: a fabricated ``Revokes prior text (): "..."`` quote whose text was never in the file's pre-amendment state, and a normative line that was removed with NO revocation marker at all. Generic: walks any globs the caller provides, against any ``--base`` commit-ish, with any ``--tag`` label, in any repo. No project-specific defaults. Exit codes: 0 — clean 1 — at least one violation (any of the four categories) 2 — usage / environment error (bad --base, not a git repo, bad regex, missing --base / --glob) See ``specs/spec-revocation-check.spec.md`` for the full contract. """ from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path # --- Collapse helpers (blockquote, list-bullet, table outer pipes, then # whitespace). Match the reference byte-for-byte so a quote extracted in this # script matches the same quote at the same base sha. _WS_RE = re.compile(r"\s+") _BLOCKQUOTE_PREFIX_RE = re.compile(r"^>+\s*") _LIST_BULLET_PREFIX_RE = re.compile(r"^(?:[-*]|\d+\.)\s+") _TABLE_ROW_OUTER_PIPE_RE = re.compile(r"^\|\s*|\s*\|$") # Markdown-only lines that are pure structure (Check B ignores them). _TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?[\s:|-]+\|?[\s:|-]*$") _CODE_FENCE_RE = re.compile(r"^\s*(```|~~~)\s*$") _HORIZONTAL_RULE_RE = re.compile(r"^\s*([-*_][\s]*){3,}\s*$") def collapse(text: str) -> str: """Strip Markdown structural prefixes per line, then collapse all whitespace to single spaces (and trim).""" lines = text.splitlines() lines = [_BLOCKQUOTE_PREFIX_RE.sub("", line) for line in lines] lines = [_LIST_BULLET_PREFIX_RE.sub("", line) for line in lines] lines = [_TABLE_ROW_OUTER_PIPE_RE.sub("", line) for line in lines] return _WS_RE.sub(" ", " ".join(lines)).strip() def is_markdown_only(collapsed: str) -> bool: """True if the collapsed text is pure Markdown structure (table separator, code fence, horizontal rule).""" if not collapsed: return True if "|" in collapsed and _TABLE_SEPARATOR_RE.match(collapsed): return True if _CODE_FENCE_RE.match(collapsed): return True if _HORIZONTAL_RULE_RE.match(collapsed): return True return False def is_free_prose_unquoted(line: str) -> bool: """True if the line is a free-prose (no-quote) revocation marker. Recognised by the presence of ``Revokes prior text:`` (the colon form, no parens) AND the absence of any ``"`` on the same physical line. Examples:: Revokes prior text: the pre-… wording **Revokes prior text:** the pre-… wording This is an older M23-era style that uses prose instead of a verbatim quote. Truly malformed lines (e.g. typos like ``Revokes prior text (M33): no quote`` or ``Revokes prior text typo``) lack the colon form or contain a ``"``, so they are not classified as free-prose. """ if "Revokes prior text:" not in line: return False return '"' not in line # --- Default marker regex builders --------------------------------------- # The two-branch no-tag pattern. We use named groups to disambiguate which # branch matched (the tagged branch's quote group is None on the untagged # branch, and vice versa). _NO_TAG_PATTERN = ( r'Revokes prior text \((?P[^)]+)\):\s*"(?P.*)"' r'|Revokes prior text:\s*"(?P.*)"' ) def default_marker_regex(tag: str | None) -> re.Pattern[str]: """Build the default per-line marker pattern. With ``--tag TAG``: ``Revokes prior text (TAG):\\s*"(.*)"``. Without ``--tag``: either the tagged form for any single parenthesised token (group ``quote_tagged``), OR the untagged form ``Revokes prior text:\\s*"(.*)"`` (group ``quote_untagged``). Greedy to the LAST ``"`` on the line so quotes containing literal ``"`` still parse. """ if tag is not None: body = rf'Revokes prior text \({re.escape(tag)}\):\s*"(.*)"' else: body = _NO_TAG_PATTERN return re.compile(body) # --- Git helpers ---------------------------------------------------------- def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["git", "-C", str(repo_root), *args], capture_output=True, text=True, ) def git_show(repo_root: Path, rev: str, rel_path: str) -> str | None: result = _git(repo_root, "show", f"{rev}:{rel_path}") if result.returncode != 0: return None return result.stdout def git_resolve(repo_root: Path, rev: str) -> str | None: """Return the full sha for ``rev``, or None if it does not resolve.""" result = _git(repo_root, "rev-parse", "--verify", f"{rev}^{{commit}}") if result.returncode != 0: return None return result.stdout.strip() def git_diff_renames(repo_root: Path, base: str) -> dict[str, str]: """Return {old_path: new_path} for renames reported by ``git diff --find-renames --name-status -M30 ``. The ``-M30`` lowers the rename-detection similarity threshold from the default 50% to 30% — a renamed-then-quoted file has very low raw similarity (the original prose line is replaced with a revocation marker), so the default threshold misses it. 30% is conservative enough to avoid spurious matches on unrelated text but loose enough to catch the rename-and-amend case the brief describes. """ result = _git(repo_root, "diff", "--find-renames", "-M30", "--name-status", "--diff-filter=R", base) out: dict[str, str] = {} if result.returncode != 0: return out for line in result.stdout.splitlines(): parts = line.split("\t") if len(parts) == 3 and parts[0].startswith("R"): _, old, new = parts out[old] = new return out # --- File enumeration ----------------------------------------------------- def iter_files(repo_root: Path, globs: list[str]) -> list[Path]: """Return every file matched by any glob, sorted, deduped.""" seen: dict[Path, None] = {} for pattern in globs: for p in sorted(repo_root.glob(pattern)): if p.is_file(): seen[p] = None return list(seen) def _glob_to_regex(pat: str) -> re.Pattern[str]: """Translate a pathlib-style glob (``*``, ``**``, ``?``) to a regex. ``**`` matches any number of path segments (including zero), consuming an optional trailing ``/``. ``*`` matches any chars except ``/``. ``?`` matches one char except ``/``. Other chars are escaped. """ out: list[str] = [] i = 0 while i < len(pat): c = pat[i] if c == "*": if i + 1 < len(pat) and pat[i + 1] == "*": out.append(".*") i += 2 if i < len(pat) and pat[i] == "/": i += 1 else: out.append("[^/]*") i += 1 elif c == "?": out.append("[^/]") i += 1 else: out.append(re.escape(c)) i += 1 return re.compile("^" + "".join(out) + "$") def iter_files_at_base(repo_root: Path, base: str, globs: list[str]) -> list[str]: """Return relative paths of every file that matched any glob at ``base``.""" result = _git(repo_root, "ls-tree", "-r", "--name-only", base) if result.returncode != 0: return [] base_paths = set(result.stdout.splitlines()) compiled = [_glob_to_regex(g) for g in globs] out: list[str] = [] seen: set[str] = set() for rel in sorted(base_paths): if rel in seen: continue if any(c.match(rel) for c in compiled): seen.add(rel) out.append(rel) return out # --- Marker parsing ------------------------------------------------------- def find_quotes_and_malformed( path: Path, marker_re: re.Pattern[str], no_tag_mode: bool, tag: str | None, strict: bool, ) -> tuple[list[tuple[int, str]], list[tuple[int, str]], int]: """Return (quotes, malformed, ignored). ``quotes``: ``[(line_no, quoted_text), ...]`` for every parseable marker. ``malformed``: ``[(line_no, raw_line), ...]`` for every candidate line that contained the marker phrase but did NOT parse. ``ignored``: count of free-prose unquoted lines seen in no-tag mode when ``strict`` is False. With ``--tag``, free-prose is impossible by construction (only literal-tagged lines are candidates), so this is always 0. In **tag mode** (``--tag TAG``, ``no_tag_mode == False``), only lines containing the literal ``Revokes prior text (TAG)`` are candidates; every other line is skipped before the regex runs, so a marker for a different tag, an untagged marker, or a free-prose marker cannot accidentally be classified as malformed or quoted. In **no-tag mode**, every line containing ``Revokes prior text`` is a candidate. A candidate that fails the multi-branch regex is split: ``is_free_prose_unquoted`` lines become ``ignored`` (or ``malformed`` if ``strict``); everything else becomes ``malformed``. """ quotes: list[tuple[int, str]] = [] malformed: list[tuple[int, str]] = [] ignored = 0 tag_literal = f"Revokes prior text ({tag})" if not no_tag_mode else None for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): if "Revokes prior text" not in line: continue if tag_literal is not None and tag_literal not in line: # With --tag, lines without the literal TAG marker are entirely # ignored — not malformed, not quoted, not counted. Their text # is never used to cover deletions in this run. continue m = marker_re.search(line) if m: if no_tag_mode: # Group 'quote_tagged' is None on the untagged branch. q = m.group("quote_tagged") if q is None: q = m.group("quote_untagged") else: q = m.group(1) quotes.append((n, q or "")) else: if no_tag_mode and is_free_prose_unquoted(line): if strict: malformed.append((n, line)) else: ignored += 1 else: malformed.append((n, line)) return quotes, malformed, ignored # --- The check itself ----------------------------------------------------- def run_checks( repo_root: Path, base_sha: str, tag: str | None, marker_re: re.Pattern[str], globs: list[str], min_line_chars: int, strict: bool, ) -> dict: files_at_head = iter_files(repo_root, globs) renames = git_diff_renames(repo_root, base_sha) head_rel_set = {str(p.relative_to(repo_root)) for p in files_at_head} # Inverse map: new -> old, for renamed files. rename_targets = {new: old for old, new in renames.items()} no_tag_mode = (tag is None) unquoted: list[dict] = [] fabricated: list[dict] = [] malformed: list[dict] = [] ignored_total = 0 # Pass 1: every file at HEAD (Check A + Check B). for path in files_at_head: rel = str(path.relative_to(repo_root)) base_text = git_show(repo_root, base_sha, rel) # If this is a renamed file, the base text lives at the OLD path. if base_text is None and rel in rename_targets: old_rel = rename_targets[rel] base_text = git_show(repo_root, base_sha, old_rel) quotes, mf, ignored = find_quotes_and_malformed( path, marker_re, no_tag_mode, tag, strict, ) ignored_total += ignored if base_text is None: # File did not exist at base (and is not a rename). Any quote is # fabricated. for ln, quote in quotes: fabricated.append({ "path": rel, "line": ln, "base": base_sha, "quote": quote, "reason": "file did not exist at base", }) for ln, raw in mf: malformed.append({ "path": rel, "line": ln, "base": base_sha, "line_text": raw, }) continue # Check A — verbatim quotes. collapsed_base = collapse(base_text) for ln, quote in quotes: cq = collapse(quote) if cq and cq not in collapsed_base: fabricated.append({ "path": rel, "line": ln, "base": base_sha, "quote": quote, }) # Malformed markers. for ln, raw in mf: malformed.append({ "path": rel, "line": ln, "base": base_sha, "line_text": raw, }) # Check B — completeness (only if file differs from base). current_text = path.read_text(encoding="utf-8") if collapse(base_text) == collapse(current_text): continue collapsed_current = collapse(current_text) collapsed_quotes = [collapse(q) for _, q in quotes] for n, line in enumerate(base_text.splitlines(), start=1): if not line.strip(): continue collapsed_old = collapse(line) if not collapsed_old: continue if len(collapsed_old) < min_line_chars: continue if is_markdown_only(collapsed_old): continue if collapsed_old in collapsed_current: continue if any(collapsed_old in q for q in collapsed_quotes): continue unquoted.append({ "path": rel, "line": n, "base": base_sha, "text": line.strip(), }) # Pass 2: files that matched at base but not at HEAD (Check D). base_paths = iter_files_at_base(repo_root, base_sha, globs) deleted: list[dict] = [] for rel in base_paths: if rel in head_rel_set: continue if rel in renames: new = renames[rel] if new in head_rel_set: continue # rename handled by Check B deleted.append({"path": rel, "base": base_sha}) return { "repo_root": str(repo_root.resolve()), "base": base_sha, "tag": tag, "strict": strict, "marker_regex": marker_re.pattern, "files_checked": sorted(head_rel_set), "violations": { "unquoted_deletions": unquoted, "fabricated_quotes": fabricated, "malformed_markers": malformed, "deleted_since_base": deleted, }, "summary": { "unquoted": len(unquoted), "fabricated": len(fabricated), "malformed": len(malformed), "deleted": len(deleted), "ignored": ignored_total, }, } # --- Output --------------------------------------------------------------- def format_human(result: dict) -> str: """Render the result dict as human-readable text.""" lines: list[str] = [] v = result["violations"] if v["unquoted_deletions"]: lines.append("unquoted deletions:") for x in v["unquoted_deletions"]: lines.append(f" {x['path']}:{x['line']} ({x['base']}): {x['text']}") if v["fabricated_quotes"]: lines.append("fabricated quotes:") for x in v["fabricated_quotes"]: reason = x.get("reason", "") if reason: lines.append(f" {x['path']}:{x['line']} ({x['base']}): {x['quote']!r} [{reason}]") else: lines.append(f" {x['path']}:{x['line']} ({x['base']}): {x['quote']!r}") if v["malformed_markers"]: lines.append("malformed markers:") for x in v["malformed_markers"]: lines.append(f" {x['path']}:{x['line']} ({x['base']}): {x['line_text']}") if v["deleted_since_base"]: lines.append("deleted since base:") for x in v["deleted_since_base"]: lines.append(f" {x['path']} ({x['base']})") if not any(v.values()) and result["summary"]["ignored"] == 0: return ( f"spec-revocation-check: clean — every {result['base']} line " "survives or is quoted." ) s = result["summary"] lines.append( f"Summary: {s['unquoted'] + s['fabricated'] + s['malformed'] + s['deleted']} " f"violation(s) — {s['unquoted']} unquoted, {s['fabricated']} fabricated, " f"{s['malformed']} malformed, {s['deleted']} deleted, " f"{s['ignored']} ignored" ) return "\n".join(lines) # --- CLI ------------------------------------------------------------------ def _validate_marker_regex(pattern: str) -> tuple[re.Pattern[str] | None, str | None]: """Compile the pattern and check it has exactly one capture group.""" try: compiled = re.compile(pattern) except re.error as e: return None, f"--marker-regex does not compile: {e}" if compiled.groups != 1: return None, ( f"--marker-regex must have exactly one capture group, got " f"{compiled.groups}. The single group is the quote text." ) return compiled, None def main(argv: list[str]) -> int: parser = argparse.ArgumentParser( description="Generic spec-amendment revocation gate.", add_help=True, ) parser.add_argument("--base", required=True, help="pre-amendment base commit-ish (required)") parser.add_argument("--tag", default=None, help="marker tag, e.g. M33 (optional)") parser.add_argument("--glob", action="append", dest="globs", default=[], help="glob (relative to --repo-root), repeatable, REQUIRED") parser.add_argument("--repo-root", default=None, help="repo root (default: git rev-parse --show-toplevel of cwd)") parser.add_argument("--marker-regex", default=None, help="override the marker regex; must have exactly one capture group") parser.add_argument("--min-line-chars", type=int, default=12, help="ignore base lines shorter than this after collapsing (default: 12)") parser.add_argument("--strict", action="store_true", help="without --tag, treat free-prose unquoted markers as malformed " "(they are otherwise counted in the summary as 'ignored')") parser.add_argument("--json", action="store_true", help="emit result as JSON on stdout") parser.add_argument("--dryrun", "-n", action="store_true", help="print resolved config then run checks anyway") args = parser.parse_args(argv) if not args.globs: print("error: --glob is required (repeatable; no project default)", file=sys.stderr) return 2 # Resolve repo root from cwd (NOT from the script's path). if args.repo_root is not None: repo_root = Path(args.repo_root).resolve() else: result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, ) if result.returncode != 0: print( f"error: not inside a git working tree " f"({result.stderr.strip() or 'git rev-parse failed'})", file=sys.stderr, ) return 2 repo_root = Path(result.stdout.strip()).resolve() # Verify --base resolves. base_sha = git_resolve(repo_root, args.base) if base_sha is None: print( f"error: --base {args.base!r} does not resolve in {repo_root}", file=sys.stderr, ) return 2 # Build / validate marker regex. if args.marker_regex is not None: marker_re, err = _validate_marker_regex(args.marker_regex) if err is not None: print(f"error: {err}", file=sys.stderr) return 2 else: marker_re = default_marker_regex(args.tag) if args.min_line_chars < 0: print("error: --min-line-chars must be >= 0", file=sys.stderr) return 2 # Dryrun: print resolved config (still run checks). if args.dryrun: head_files = iter_files(repo_root, args.globs) print(f"[dryrun] repo_root: {repo_root}") print(f"[dryrun] base: {args.base} -> {base_sha}") print(f"[dryrun] tag: {args.tag if args.tag else 'None'}") print(f"[dryrun] strict: {args.strict}") print(f"[dryrun] marker: {marker_re.pattern}") print(f"[dryrun] min_line_chars: {args.min_line_chars}") print(f"[dryrun] files: {len(head_files)}") for p in head_files: print(f"[dryrun] - {p.relative_to(repo_root)}") print() result = run_checks( repo_root=repo_root, base_sha=base_sha, tag=args.tag, marker_re=marker_re, globs=args.globs, min_line_chars=args.min_line_chars, strict=args.strict, ) if args.json: json.dump(result, sys.stdout, indent=2, sort_keys=False) sys.stdout.write("\n") print(format_human(result), file=sys.stderr) else: print(format_human(result)) s = result["summary"] total = s["unquoted"] + s["fabricated"] + s["malformed"] + s["deleted"] return 1 if total > 0 else 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))