fix(spec-revocation-check): --tag scopes marker parsing; free-prose ignored by default (brief)

This commit is contained in:
Paul O'Reilly
2026-09-24 14:19:48 +12:00
parent 2715162e73
commit c706e8ddcd
3 changed files with 221 additions and 28 deletions

View File

@@ -38,7 +38,8 @@ spec-revocation-check --base <commit-ish> [--tag TAG]
| Argument | Default | Description |
|----------|---------|-------------|
| `--tag TAG` | — | Restrict the marker regex to `(TAG)`. Without it, match any tag AND the untagged form `Revokes prior text: "<quote>"`. |
| `--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. |
@@ -66,7 +67,12 @@ spec-revocation-check --base <commit-ish> [--tag TAG]
- 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).
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
@@ -82,18 +88,32 @@ containing `"`" case, which only works because of the greedy match.
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
- 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).
- 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.)
- **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
@@ -107,7 +127,9 @@ DIFFERS from `--base`:
`***` / `___`), 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.
(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
@@ -142,7 +164,7 @@ malformed markers:
deleted since base:
spec/old.md (<base>)
Summary: 3 violation(s) — 1 unquoted, 1 fabricated, 1 malformed, 0 deleted
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
@@ -168,7 +190,7 @@ If there are zero violations: `spec-revocation-check: clean — every
"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}
"summary": {"unquoted": 1, "fabricated": 1, "malformed": 1, "deleted": 0, "ignored": 2}
}
```
@@ -183,6 +205,7 @@ 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>
@@ -210,7 +233,9 @@ to read the repo and report.)
| 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). |
| 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). |
@@ -257,4 +282,8 @@ spec-revocation-check --base $BASE_SHA --glob 'spec/**/*.md' --json
| 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. |
| 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. |