Files
small-scripts/specs/spec-revocation-check.spec.md

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:

  1. Fabricated quote — a Revokes prior text (<TAG>): "..." 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 <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

  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 <base>). 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 <base> 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): <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.