feat(spec-revocation-check): generic spec-amendment revocation gate (brief)
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).
This commit is contained in:
260
specs/spec-revocation-check.spec.md
Normal file
260
specs/spec-revocation-check.spec.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# 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>"`. |
|
||||
| `--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).
|
||||
- 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:
|
||||
|
||||
- Parse all marker occurrences in the file's current text. Collect
|
||||
`(line_no, quote_text)`.
|
||||
- For each parsed 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).
|
||||
- A line that contains `Revokes prior text` but does NOT match the marker
|
||||
regex is a "malformed marker" violation. (Catches a typo'd or reworded
|
||||
marker that would otherwise silently carry no enforceable quote.)
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
```
|
||||
|
||||
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}
|
||||
}
|
||||
```
|
||||
|
||||
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] 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). |
|
||||
| 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. |
|
||||
Reference in New Issue
Block a user