Catches two failure modes that each felled a previous spec-amendment attempt: a fabricated Revokes-prior-text quote (text not in the file at --base) and a normative line silently deleted with no quote at all. Generic: any --glob x --base <sha> against any repo, with optional --tag filter. Reuses the collapse() / quote_line_re() shape from the M33 reference, adds a Check D for deleted/renamed files (-M30 rename threshold), and four violation categories: unquoted deletions, fabricated quotes, malformed markers, deleted since base. Stdlib Python only, --json output, --dryrun prints resolved config and still runs the checks (read-only by nature).
522 lines
18 KiB
Python
Executable File
522 lines
18 KiB
Python
Executable File
#!/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 (<TAG>): "..."`` 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
|
|
|
|
|
|
# --- 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<tag>[^)]+)\):\s*"(?P<quote_tagged>.*)"'
|
|
r'|Revokes prior text:\s*"(?P<quote_untagged>.*)"'
|
|
)
|
|
|
|
|
|
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 <base>``.
|
|
|
|
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,
|
|
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
|
"""Return (quotes, malformed).
|
|
|
|
``quotes``: [(line_no, quoted_text), ...] for every parseable marker.
|
|
``malformed``: [(line_no, raw_line), ...] for every line containing the
|
|
phrase ``Revokes prior text`` that did NOT parse.
|
|
"""
|
|
quotes: list[tuple[int, str]] = []
|
|
malformed: list[tuple[int, str]] = []
|
|
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
if "Revokes prior text" not in line:
|
|
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:
|
|
malformed.append((n, line))
|
|
return quotes, malformed
|
|
|
|
|
|
# --- 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,
|
|
) -> 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] = []
|
|
|
|
# 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 = find_quotes_and_malformed(path, marker_re, no_tag_mode)
|
|
|
|
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,
|
|
"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),
|
|
},
|
|
}
|
|
|
|
|
|
# --- 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()):
|
|
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"
|
|
)
|
|
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("--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] 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,
|
|
)
|
|
|
|
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:])) |