16 KiB
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:
- Fabricated quote — a
Revokes prior text (<TAG>): "..."line whose quoted text does not actually exist in the file's pre-amendment state. - 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 <commit-ish> [--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 <commit-ish> |
Pre-amendment base commit / tag / branch to diff against. Must resolve in the repo (git cat-file -e <base>); 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: "<quote>". 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
- Resolve
--repo-root(default:git rev-parse --show-toplevelof the current working directory). If it is missing or not inside a git working tree, exit 2 with an error message on stderr. - Verify
--baseresolves in that repo (git cat-file -e <base>). Exit 2 with an error message on stderr if it does not. - Validate
--marker-regex(if supplied) compiles and has exactly one capture group. Exit 2 with an error message on stderr if it does not. - 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--baseare noted for Check A; files that exist at--basebut 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 isre.search, so the marker can appear mid-line inside a Markdown table cell). Lines that do not contain the literalRevokes 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 formRevokes 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 REGEXreplaces 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 literalRevokes 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--strictrecord it as a malformed-marker violation, otherwise silently ignore it (count in the summary asignored (unquoted-style)but do not contribute to the exit code). - Otherwise record it as a malformed-marker violation.
- If the line is a free-prose unquoted marker — i.e. it
contains
- With
- 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--baseafter 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
--basebut does NOT exist at HEAD: report a "deleted since base" violation, UNLESSgit diff --find-renames --name-status -M30 <base>reports the file as renamed. -M30lowers 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): <text>:
unquoted deletions:
spec/foo.md:7 (<base>): Every render MUST do the thing.
fabricated quotes:
spec/bar.md:12 (<base>): 'This sentence was never in the base.'
malformed markers:
spec/baz.md:3 (<base>): Revokes prior text (M33) "missing colon"
deleted since base:
spec/old.md (<base>)
Summary: 3 violation(s) — 1 unquoted, 1 fabricated, 1 malformed, 0 deleted, 2 ignored
The <text> for unquoted deletions is the original base line (with
leading/trailing whitespace stripped). The <text> for fabricated quotes
is the parsed quote text. The <text> for malformed markers is the raw
line.
If there are zero violations: `spec-revocation-check: clean — every
line survives or is quoted.`JSON (--json)
{
"repo_root": "/abs/path",
"base": "<resolved-sha>",
"tag": "<TAG or null>",
"marker_regex": "<compiled-pattern-source>",
"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: <abs-path>
[dryrun] base: <base-as-arg> -> <resolved-sha>
[dryrun] tag: <TAG-or-None>
[dryrun] strict: <True-or-False>
[dryrun] marker: <regex-source>
[dryrun] min_line_chars: 12
[dryrun] files: <N>
[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
# 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 ` |
| 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 <base> 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): <text>. 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. |