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

289 lines
16 KiB
Markdown

# 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
<base> line survives or is quoted.`
#### JSON (`--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
```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 <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. |