# spec-revocation-check ## Purpose Generic spec-amendment revocation gate for any git repository. Catches two failure modes that have each felled a previous spec-amendment attempt: 1. **Fabricated quote** — a `Revokes prior text (): "..."` line whose quoted text does not actually exist in the file's pre-amendment state. 2. **Unquoted deletion** — a normative line is removed from the file with no revocation marker at all. The script is generic: it walks any globs the caller provides, against any `--base` commit-ish, with any `--tag` label, in any repo. No project-specific defaults. ## Usage ``` spec-revocation-check --base [--tag TAG] --glob PATTERN [--glob ...] [--repo-root DIR] [--marker-regex REGEX] [--min-line-chars N] [--json] [--dryrun|-n] [--help|-h] ``` ### Required arguments | Argument | Description | |----------|-------------| | `--base ` | Pre-amendment base commit / tag / branch to diff against. Must resolve in the repo (`git cat-file -e `); exit 2 otherwise. | | `--glob PATTERN` | Glob (relative to `--repo-root`, `**` supported) selecting files to check. Repeatable. Must appear at least once; no default. | ### Optional arguments | Argument | Default | Description | |----------|---------|-------------| | `--tag TAG` | — | Restrict the marker regex to `(TAG)`. Without it, match any tag AND the untagged form `Revokes prior text: ""`. With `--tag`, lines that do not contain the literal `Revokes prior text (TAG)` are entirely ignored — not parsed, not used as quotes, not counted. | | `--strict` | off | Without `--tag`, lines that look like free-prose unquoted revocation markers (e.g. `**Revokes prior text:** the pre-… wording`) are reported as malformed. Default is to count them in the summary as `ignored (unquoted-style)` without failing. No effect with `--tag` (a non-parsing `(TAG)` line is already malformed). | | `--marker-regex REGEX` | built-in default | Override the line-shape regex. Must have exactly one capture group (the quoted text); exit 2 if it has zero or more than one. | | `--min-line-chars N` | `12` | Ignore non-blank base lines whose collapsed length is shorter than this (e.g. short structural lines). After collapsing. | | `--repo-root DIR` | `git rev-parse --show-toplevel` of cwd | Repo root. Never inferred from the script's own path. | | `--json` | off | Emit the result as JSON on stdout; human-readable summary still goes to stderr. | | `--dryrun`, `-n` | off | Read-only anyway. Prints resolved repo root, base sha, file list, marker regex, then runs the checks. | | `--help`, `-h` | — | Show usage and exit 0. | ## Behaviour ### 1. Resolve environment 1. Resolve `--repo-root` (default: `git rev-parse --show-toplevel` of the current working directory). If it is missing or not inside a git working tree, exit 2 with an error message on stderr. 2. Verify `--base` resolves in that repo (`git cat-file -e `). Exit 2 with an error message on stderr if it does not. 3. Validate `--marker-regex` (if supplied) compiles and has exactly one capture group. Exit 2 with an error message on stderr if it does not. 4. Collect the file list: every file matched by any `--glob` (relative to `--repo-root`, walking the repo). Files that exist at HEAD but not at `--base` are noted for Check A; files that exist at `--base` but not at HEAD are noted for Check D (deleted). ### 2. Build the marker regex - Default, with `--tag TAG`: `Revokes prior text \(TAG\):\s*"(.*)"` (the match is `re.search`, so the marker can appear mid-line inside a Markdown table cell). Lines that do not contain the literal `Revokes prior text (TAG)` are not even candidates — they are skipped before the regex runs, so a tagged marker for a different tag, an untagged marker, or a free-prose marker cannot accidentally be classified as malformed. (Defect from round 1: with `--tag M33`, every M23 / untagged / free-prose marker was being reported as malformed.) - Default, without `--tag`: match either the tagged form for any single parenthesised token, OR the untagged form `Revokes prior text:\s*"(.*)"`. The untagged form is the M32-era marker; we keep it recognised so older revocation chains stay auditable. - A custom `--marker-regex REGEX` replaces the entire pattern. It must compile and have exactly one capture group (the quote); exit 2 otherwise. The quote is greedy to the LAST `"` on the same physical source line. This is deliberate — see `tests/test-spec-revocation-check.sh`'s "quote containing `"`" case, which only works because of the greedy match. ### 3. Check A — verbatim quotes For every file matched by a glob at HEAD: - Walk the file line by line. For each line containing the phrase `Revokes prior text`: - **With `--tag TAG`**: if the line does not contain the literal `Revokes prior text (TAG)`, skip it entirely (not parsed, not reported, its text not used as a quote). Otherwise try the marker regex; on match, record `(line_no, quote_text)`. On miss, record a "malformed marker" violation. - **Without `--tag`**: try the multi-branch marker regex (tagged-any | untagged); on match, record `(line_no, quote_text)`. On miss, classify the line: - If the line is a **free-prose unquoted marker** — i.e. it contains `Revokes prior text:` (the colon form, no parens) AND has no `"` anywhere on the line — then with `--strict` record it as a malformed-marker violation, otherwise silently ignore it (count in the summary as `ignored (unquoted-style)` but do not contribute to the exit code). - Otherwise record it as a malformed-marker violation. - For each recorded quote, collapse (whitespace-collapse, strip Markdown structural prefixes — blockquote `>`, list bullet `- * 1.`, table outer pipes) and assert it is a substring of the SAME file at `--base` after the same collapse. - If the file did not exist at `--base`, every parsed quote is a violation (a "fabricated quote" — you cannot revoke text from a file that did not yet exist). - **No-quote markers (free-prose) are never used as quote text to cover deletions**, regardless of mode: they have no quote to extract. ### 4. Check B — completeness For every file matched by a glob at HEAD that ALSO exists at `--base` and DIFFERS from `--base`: - For each non-blank line in the base file, collapse it. If the collapsed line is shorter than `--min-line-chars`, skip it. - If the collapsed base line is pure Markdown structure (a table separator `|---|---|`, a code fence ``` ` ``` ```, or a horizontal rule `---` / `***` / `___`), skip it. - Otherwise, the collapsed base line MUST be present in either: (a) the current file's collapsed text, OR (b) the collapsed text of any of that file's parsed quotes (NOT free- prose lines — they have no quote text and so cannot cover a deletion). - If neither, it is an "unquoted deletion" violation. ### 5. Check D — deleted / renamed files - For every file that matched a glob at `--base` but does NOT exist at HEAD: report a "deleted since base" violation, UNLESS `git diff --find-renames --name-status -M30 ` reports the file as renamed. - `-M30` lowers git's rename-detection similarity threshold from the default 50% to 30%: a renamed-then-amended 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 this check exists for. - For a renamed file, the new path is matched by the globs at HEAD (if it is), so Check A and Check B run against the renamed file's current text versus the OLD path's base text — the rename is itself a revocation; the new content still must justify the old content. ### 6. Output #### Human-readable (default) Group violations under headers, then list each as `path:line (base): `: ``` unquoted deletions: spec/foo.md:7 (): Every render MUST do the thing. fabricated quotes: spec/bar.md:12 (): 'This sentence was never in the base.' malformed markers: spec/baz.md:3 (): Revokes prior text (M33) "missing colon" deleted since base: spec/old.md () Summary: 3 violation(s) — 1 unquoted, 1 fabricated, 1 malformed, 0 deleted, 2 ignored ``` The `` for `unquoted deletions` is the original base line (with leading/trailing whitespace stripped). The `` for `fabricated quotes` is the parsed quote text. The `` for `malformed markers` is the raw line. If there are zero violations: `spec-revocation-check: clean — every line survives or is quoted.` #### JSON (`--json`) ```json { "repo_root": "/abs/path", "base": "", "tag": "", "marker_regex": "", "files_checked": ["spec/foo.md", "spec/bar.md"], "violations": { "unquoted_deletions": [{"path": "spec/foo.md", "line": 7, "base": "...", "text": "..."}], "fabricated_quotes": [{"path": "spec/bar.md", "line": 12, "base": "...", "quote": "..."}], "malformed_markers": [{"path": "spec/baz.md", "line": 3, "base": "...", "line_text": "..."}], "deleted_since_base": [{"path": "spec/old.md", "base": "..."}] }, "summary": {"unquoted": 1, "fabricated": 1, "malformed": 1, "deleted": 0, "ignored": 2} } ``` The human-readable summary still goes to stderr under `--json` so callers can see a one-liner. ### 7. Dryrun behaviour The script is read-only by nature. `--dryrun` (and `-n`) prints: ``` [dryrun] repo_root: [dryrun] base: -> [dryrun] tag: [dryrun] strict: [dryrun] marker: [dryrun] min_line_chars: 12 [dryrun] files: [dryrun] - spec/foo.md [dryrun] - spec/bar.md ``` …then runs the checks anyway and reports the result normally. (A dryrun that did NOT run the checks would be useless — the script's whole job is to read the repo and report.) ### 8. Exit codes | Code | Meaning | |------|---------| | 0 | Clean — zero violations. | | 1 | At least one violation across any of the four categories. | | 2 | Usage / environment error: missing `--base`, missing `--glob`, bad `--base` (does not resolve), not inside a git repo, bad `--marker-regex` (does not compile or wrong capture-group count). | ## Edge cases | Case | Handling | |------|----------| | File matched at HEAD, did not exist at base | Check A applies (any quote is a violation); Check B is skipped. | | File matched at base, does not exist at HEAD | Reported as `deleted since base` (Check D); unless git says renamed, in which case Check B is run on the renamed file. | | File matched at base, identical at HEAD | Skipped entirely. | | File has `Revokes prior text` but no closing `"` on the same line | Reported as `malformed marker` (Check A), unless the line is a free-prose unquoted marker (see below). | | With `--tag X`, line has `Revokes prior text (Y): "..."` for a different tag Y | Line is entirely ignored (not parsed, not used as a quote, not counted). Y's text is NOT used to cover deletions in this run. | | Free-prose unquoted marker (no `--tag`): line contains `Revokes prior text:` (colon form, no parens) and has no `"` anywhere on the line | Without `--strict`: counted in the summary as `ignored (unquoted-style)`; does not contribute to exit code; never used as quote text to cover deletions. With `--strict`: reported as `malformed marker`. | | Quote text contains literal `"` characters | Handled by greedy match to the LAST `"` on the line. | | Two quote markers on one line (e.g. two revoked cells of one table row) | Both parsed separately by the regex (greedy `.*"` finds each tail `"`); each checked independently. | | Base file has only blank lines | Skipped (no non-blank base lines to lose). | | Tag in the marker has special regex characters | Escaped via `re.escape(tag)` in the default marker. | | `--repo-root` is a symlink | Resolved with `Path.resolve()`. | | `--marker-regex` matches zero or >1 capture groups | Error to stderr, exit 2. | | `--marker-regex` does not compile | Error to stderr, exit 2. | | `--min-line-chars` is negative | Error to stderr, exit 2. | ## Examples ```bash # Pre-merge review for an M-tagged amendment: spec-revocation-check --base origin/main --tag M99 \ --glob 'spec/**/*.md' --glob 'FUTURE.md' # Just a single file, no tag filter: spec-revocation-check --base HEAD~1 \ --glob 'docs/api-contract.md' # Custom marker shape (e.g. M32's untagged marker only): spec-revocation-check --base bd899c9 \ --glob 'spec/**/*.md' \ --marker-regex '^Revokes prior text:\s*"(.*)"' # JSON output for CI parsing: spec-revocation-check --base $BASE_SHA --glob 'spec/**/*.md' --json ``` ## Requirements index | ID | Requirement | |----|-------------| | R1 | Repo root defaults to `git rev-parse --show-toplevel` of cwd (never the script's own location). | | R2 | `--glob` is required, repeatable, `**` supported, relative to repo root. No project defaults. | | R3 | Default marker regex with `--tag` is `Revokes prior text \(TAG\):\s*"(.*)"`. Without `--tag`, matches any `(TAG)` AND the untagged form `Revokes prior text: "..."`. | | R4 | `--marker-regex REGEX` overrides the default. Must compile AND have exactly one capture group; exit 2 otherwise. | | R5 | Quote extends to the LAST `"` on the same physical source line (greedy). | | R6 | Check A: every parsed quote, after collapse, must be a substring of the SAME file at `--base` after the same collapse. File that did not exist at base → all quotes are violations. | | R7 | Check A also flags a line containing `Revokes prior text` that fails to parse as a `malformed marker`. | | R8 | Check B: for each file matched at HEAD and differing from base, every non-blank base line (collapsed, not shorter than `--min-line-chars`, not pure-Markdown-structure) must survive in the current file or be contained in one of that file's quotes. | | R9 | Markdown-only base lines (table separators like `|---|---|`, fences ``` ` ``` ```, horizontal rules `---`/`***`/`___`) are ignored by Check B. | | R10 | Check D: a file that matched a glob at base but does not exist at HEAD is a `deleted since base` violation unless `git diff --find-renames --name-status -M30 ` reports it renamed, in which case Check A and Check B run against the renamed file (treating the old path's base content as the baseline). | | R11 | Output groups violations under `unquoted deletions`, `fabricated quotes`, `malformed markers`, `deleted since base`. Each violation is `path:line (base): `. Summary line at the end. | | R12 | `--json` emits the same data as a single JSON object on stdout (human-readable summary still on stderr). | | R13 | Exit 0 clean, 1 any violation, 2 usage/environment error. | | R14 | `--dryrun` prints resolved repo root, base sha, tag, marker regex, file list, then runs the checks anyway. | | R15 | With `--tag TAG`, lines that do not contain the literal `Revokes prior text (TAG)` are entirely ignored: not parsed, not reported, and their text is not used as a quote to cover deletions. Only lines containing that literal are candidates; candidates that fail the marker regex are `malformed markers`. | | R16 | Without `--tag`, a line containing `Revokes prior text` that fails the marker regex is classified as `free-prose unquoted` if it contains `Revokes prior text:` (the colon form, no parens) and has no `"` on the same line. With `--strict`, free-prose lines are `malformed markers`; otherwise they are counted in the summary as `ignored (unquoted-style)` and never contribute to the exit code. | | R17 | Free-prose unquoted markers are NEVER used as quote text to cover deletions, in either tag or no-tag mode. | | R18 | Summary line (human and JSON) gains an `ignored` count for free-prose unquoted markers seen in the current run. The human summary ends with `, I ignored` where I is the ignored count. The JSON `summary` dict gains an `"ignored"` integer key. |