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

@@ -65,6 +65,26 @@ def is_markdown_only(collapsed: str) -> bool:
return False return False
def is_free_prose_unquoted(line: str) -> bool:
"""True if the line is a free-prose (no-quote) revocation marker.
Recognised by the presence of ``Revokes prior text:`` (the colon form,
no parens) AND the absence of any ``"`` on the same physical line.
Examples::
Revokes prior text: the pre-… wording
**Revokes prior text:** the pre-… wording
This is an older M23-era style that uses prose instead of a verbatim
quote. Truly malformed lines (e.g. typos like ``Revokes prior text
(M33): no quote`` or ``Revokes prior text typo``) lack the colon form
or contain a ``"``, so they are not classified as free-prose.
"""
if "Revokes prior text:" not in line:
return False
return '"' not in line
# --- Default marker regex builders --------------------------------------- # --- Default marker regex builders ---------------------------------------
# The two-branch no-tag pattern. We use named groups to disambiguate which # The two-branch no-tag pattern. We use named groups to disambiguate which
@@ -207,18 +227,42 @@ def find_quotes_and_malformed(
path: Path, path: Path,
marker_re: re.Pattern[str], marker_re: re.Pattern[str],
no_tag_mode: bool, no_tag_mode: bool,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: tag: str | None,
"""Return (quotes, malformed). strict: bool,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]], int]:
"""Return (quotes, malformed, ignored).
``quotes``: [(line_no, quoted_text), ...] for every parseable marker. ``quotes``: ``[(line_no, quoted_text), ...]`` for every parseable marker.
``malformed``: [(line_no, raw_line), ...] for every line containing the ``malformed``: ``[(line_no, raw_line), ...]`` for every candidate line
phrase ``Revokes prior text`` that did NOT parse. that contained the marker phrase but did NOT parse.
``ignored``: count of free-prose unquoted lines seen in no-tag mode
when ``strict`` is False. With ``--tag``, free-prose is impossible by
construction (only literal-tagged lines are candidates), so this is
always 0.
In **tag mode** (``--tag TAG``, ``no_tag_mode == False``), only lines
containing the literal ``Revokes prior text (TAG)`` are candidates;
every other line is skipped before the regex runs, so a marker for a
different tag, an untagged marker, or a free-prose marker cannot
accidentally be classified as malformed or quoted.
In **no-tag mode**, every line containing ``Revokes prior text`` is a
candidate. A candidate that fails the multi-branch regex is split:
``is_free_prose_unquoted`` lines become ``ignored`` (or ``malformed``
if ``strict``); everything else becomes ``malformed``.
""" """
quotes: list[tuple[int, str]] = [] quotes: list[tuple[int, str]] = []
malformed: list[tuple[int, str]] = [] malformed: list[tuple[int, str]] = []
ignored = 0
tag_literal = f"Revokes prior text ({tag})" if not no_tag_mode else None
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if "Revokes prior text" not in line: if "Revokes prior text" not in line:
continue continue
if tag_literal is not None and tag_literal not in line:
# With --tag, lines without the literal TAG marker are entirely
# ignored — not malformed, not quoted, not counted. Their text
# is never used to cover deletions in this run.
continue
m = marker_re.search(line) m = marker_re.search(line)
if m: if m:
if no_tag_mode: if no_tag_mode:
@@ -230,8 +274,14 @@ def find_quotes_and_malformed(
q = m.group(1) q = m.group(1)
quotes.append((n, q or "")) quotes.append((n, q or ""))
else: else:
malformed.append((n, line)) if no_tag_mode and is_free_prose_unquoted(line):
return quotes, malformed if strict:
malformed.append((n, line))
else:
ignored += 1
else:
malformed.append((n, line))
return quotes, malformed, ignored
# --- The check itself ----------------------------------------------------- # --- The check itself -----------------------------------------------------
@@ -243,6 +293,7 @@ def run_checks(
marker_re: re.Pattern[str], marker_re: re.Pattern[str],
globs: list[str], globs: list[str],
min_line_chars: int, min_line_chars: int,
strict: bool,
) -> dict: ) -> dict:
files_at_head = iter_files(repo_root, globs) files_at_head = iter_files(repo_root, globs)
renames = git_diff_renames(repo_root, base_sha) renames = git_diff_renames(repo_root, base_sha)
@@ -255,6 +306,7 @@ def run_checks(
unquoted: list[dict] = [] unquoted: list[dict] = []
fabricated: list[dict] = [] fabricated: list[dict] = []
malformed: list[dict] = [] malformed: list[dict] = []
ignored_total = 0
# Pass 1: every file at HEAD (Check A + Check B). # Pass 1: every file at HEAD (Check A + Check B).
for path in files_at_head: for path in files_at_head:
@@ -265,7 +317,10 @@ def run_checks(
old_rel = rename_targets[rel] old_rel = rename_targets[rel]
base_text = git_show(repo_root, base_sha, old_rel) base_text = git_show(repo_root, base_sha, old_rel)
quotes, mf = find_quotes_and_malformed(path, marker_re, no_tag_mode) quotes, mf, ignored = find_quotes_and_malformed(
path, marker_re, no_tag_mode, tag, strict,
)
ignored_total += ignored
if base_text is None: if base_text is None:
# File did not exist at base (and is not a rename). Any quote is # File did not exist at base (and is not a rename). Any quote is
@@ -342,6 +397,7 @@ def run_checks(
"repo_root": str(repo_root.resolve()), "repo_root": str(repo_root.resolve()),
"base": base_sha, "base": base_sha,
"tag": tag, "tag": tag,
"strict": strict,
"marker_regex": marker_re.pattern, "marker_regex": marker_re.pattern,
"files_checked": sorted(head_rel_set), "files_checked": sorted(head_rel_set),
"violations": { "violations": {
@@ -355,6 +411,7 @@ def run_checks(
"fabricated": len(fabricated), "fabricated": len(fabricated),
"malformed": len(malformed), "malformed": len(malformed),
"deleted": len(deleted), "deleted": len(deleted),
"ignored": ignored_total,
}, },
} }
@@ -387,7 +444,7 @@ def format_human(result: dict) -> str:
for x in v["deleted_since_base"]: for x in v["deleted_since_base"]:
lines.append(f" {x['path']} ({x['base']})") lines.append(f" {x['path']} ({x['base']})")
if not any(v.values()): if not any(v.values()) and result["summary"]["ignored"] == 0:
return ( return (
f"spec-revocation-check: clean — every {result['base']} line " f"spec-revocation-check: clean — every {result['base']} line "
"survives or is quoted." "survives or is quoted."
@@ -397,7 +454,8 @@ def format_human(result: dict) -> str:
lines.append( lines.append(
f"Summary: {s['unquoted'] + s['fabricated'] + s['malformed'] + s['deleted']} " f"Summary: {s['unquoted'] + s['fabricated'] + s['malformed'] + s['deleted']} "
f"violation(s) — {s['unquoted']} unquoted, {s['fabricated']} fabricated, " f"violation(s) — {s['unquoted']} unquoted, {s['fabricated']} fabricated, "
f"{s['malformed']} malformed, {s['deleted']} deleted" f"{s['malformed']} malformed, {s['deleted']} deleted, "
f"{s['ignored']} ignored"
) )
return "\n".join(lines) return "\n".join(lines)
@@ -435,6 +493,9 @@ def main(argv: list[str]) -> int:
help="override the marker regex; must have exactly one capture group") help="override the marker regex; must have exactly one capture group")
parser.add_argument("--min-line-chars", type=int, default=12, parser.add_argument("--min-line-chars", type=int, default=12,
help="ignore base lines shorter than this after collapsing (default: 12)") help="ignore base lines shorter than this after collapsing (default: 12)")
parser.add_argument("--strict", action="store_true",
help="without --tag, treat free-prose unquoted markers as malformed "
"(they are otherwise counted in the summary as 'ignored')")
parser.add_argument("--json", action="store_true", parser.add_argument("--json", action="store_true",
help="emit result as JSON on stdout") help="emit result as JSON on stdout")
parser.add_argument("--dryrun", "-n", action="store_true", parser.add_argument("--dryrun", "-n", action="store_true",
@@ -490,6 +551,7 @@ def main(argv: list[str]) -> int:
print(f"[dryrun] repo_root: {repo_root}") print(f"[dryrun] repo_root: {repo_root}")
print(f"[dryrun] base: {args.base} -> {base_sha}") print(f"[dryrun] base: {args.base} -> {base_sha}")
print(f"[dryrun] tag: {args.tag if args.tag else 'None'}") print(f"[dryrun] tag: {args.tag if args.tag else 'None'}")
print(f"[dryrun] strict: {args.strict}")
print(f"[dryrun] marker: {marker_re.pattern}") print(f"[dryrun] marker: {marker_re.pattern}")
print(f"[dryrun] min_line_chars: {args.min_line_chars}") print(f"[dryrun] min_line_chars: {args.min_line_chars}")
print(f"[dryrun] files: {len(head_files)}") print(f"[dryrun] files: {len(head_files)}")
@@ -504,6 +566,7 @@ def main(argv: list[str]) -> int:
marker_re=marker_re, marker_re=marker_re,
globs=args.globs, globs=args.globs,
min_line_chars=args.min_line_chars, min_line_chars=args.min_line_chars,
strict=args.strict,
) )
if args.json: if args.json:

View File

@@ -38,7 +38,8 @@ spec-revocation-check --base <commit-ish> [--tag TAG]
| Argument | Default | Description | | 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. | | `--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. | | `--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. | | `--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 - Default, with `--tag TAG`: `Revokes prior text \(TAG\):\s*"(.*)"` (the
match is `re.search`, so the marker can appear mid-line inside a Markdown 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 - Default, without `--tag`: match either the tagged form for any single
parenthesised token, OR the untagged form `Revokes prior text:\s*"(.*)"`. parenthesised token, OR the untagged form `Revokes prior text:\s*"(.*)"`.
The untagged form is the M32-era marker; we keep it recognised so older 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: For every file matched by a glob at HEAD:
- Parse all marker occurrences in the file's current text. Collect - Walk the file line by line. For each line containing the phrase
`(line_no, quote_text)`. `Revokes prior text`:
- For each parsed quote, collapse (whitespace-collapse, strip Markdown - **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 structural prefixes — blockquote `>`, list bullet `- * 1.`, table outer
pipes) and assert it is a substring of the SAME file at `--base` after pipes) and assert it is a substring of the SAME file at `--base` after
the same collapse. the same collapse.
- If the file did not exist at `--base`, every parsed quote is a violation - 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 (a "fabricated quote" — you cannot revoke text from a file that did not
yet exist). yet exist).
- A line that contains `Revokes prior text` but does NOT match the marker - **No-quote markers (free-prose) are never used as quote text to cover
regex is a "malformed marker" violation. (Catches a typo'd or reworded deletions**, regardless of mode: they have no quote to extract.
marker that would otherwise silently carry no enforceable quote.)
### 4. Check B — completeness ### 4. Check B — completeness
@@ -107,7 +127,9 @@ DIFFERS from `--base`:
`***` / `___`), skip it. `***` / `___`), skip it.
- Otherwise, the collapsed base line MUST be present in either: - Otherwise, the collapsed base line MUST be present in either:
(a) the current file's collapsed text, OR (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. - If neither, it is an "unquoted deletion" violation.
### 5. Check D — deleted / renamed files ### 5. Check D — deleted / renamed files
@@ -142,7 +164,7 @@ malformed markers:
deleted since base: deleted since base:
spec/old.md (<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 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": "..."}], "malformed_markers": [{"path": "spec/baz.md", "line": 3, "base": "...", "line_text": "..."}],
"deleted_since_base": [{"path": "spec/old.md", "base": "..."}] "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] repo_root: <abs-path>
[dryrun] base: <base-as-arg> -> <resolved-sha> [dryrun] base: <base-as-arg> -> <resolved-sha>
[dryrun] tag: <TAG-or-None> [dryrun] tag: <TAG-or-None>
[dryrun] strict: <True-or-False>
[dryrun] marker: <regex-source> [dryrun] marker: <regex-source>
[dryrun] min_line_chars: 12 [dryrun] min_line_chars: 12
[dryrun] files: <N> [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 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, 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 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. | | 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. | | 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). | | Base file has only blank lines | Skipped (no non-blank base lines to lose). |
@@ -258,3 +283,7 @@ spec-revocation-check --base $BASE_SHA --glob 'spec/**/*.md' --json
| R12 | `--json` emits the same data as a single JSON object on stdout (human-readable summary still on stderr). | | 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. | | 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. |

View File

@@ -229,21 +229,36 @@ assert_exit "untagged marker no --tag exits 0" 0 "$rc" "$out" ""
assert_contains "untagged marker no --tag says clean" "clean" "$out" assert_contains "untagged marker no --tag says clean" "clean" "$out"
# ---- Test 11: --tag filters other tags --------------------------------- # ---- Test 11: --tag filters other tags ---------------------------------
# With --tag X, lines containing markers for tag Y (or untagged or free-
# prose) are entirely ignored: not parsed, not reported, not used as
# quotes. The base line that the foreign marker quoted is still in the
# current file (inside the ignored marker text), so Check B still passes.
echo "=== --tag filters other tags ===" echo "=== --tag filters other tags ==="
T="$SCRATCH/t11"; mk_repo "$T" T="$SCRATCH/t11"; mk_repo "$T"
commit_file "$T" "spec/foo.md" \ commit_file "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null
BASE=$(git -C "$T" rev-parse HEAD) BASE=$(git -C "$T" rev-parse HEAD)
# Quote uses M33, but we run with --tag M99, so the marker is malformed # Quote uses M33, but we run with --tag M99 — the M33 marker is ignored
# (no closing quote because the regex requires "M99"). # entirely (not parsed as malformed). The base line text is still in the
# current file (inside the ignored marker), so Check B still passes.
write_file_via_py "$T" "spec/foo.md" \ write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "A line that gets revoked."\n\n' $'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "A line that gets revoked."\n\n'
out=$(run_check "$T" "$BASE" --tag M99); rc=$? out=$(run_check "$T" "$BASE" --tag M99); rc=$?
assert_exit "--tag filters other tag exits 1" 1 "$rc" "$out" "" assert_exit "--tag M99 ignores M33 marker exits 0" 0 "$rc" "$out" ""
# Without --tag, the marker WOULD have parsed and the line would survive. assert_contains "--tag M99 ignores M33 says clean" "clean" "$out"
# Without --tag, the marker parses and the line is covered.
out_notag=$(run_check "$T" "$BASE"); rc_notag=$? out_notag=$(run_check "$T" "$BASE"); rc_notag=$?
assert_exit "no --tag accepts M33 marker exits 0" 0 "$rc_notag" "$out_notag" "" assert_exit "no --tag accepts M33 marker exits 0" 0 "$rc_notag" "$out_notag" ""
# Now run --tag M99 with an M33 marker that quotes something OTHER than
# a base line — the base line is then truly unquoted, and with --tag M99
# the M33 marker doesn't cover it → unquoted deletion → violation.
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "Some other thing entirely."\n\n'
out=$(run_check "$T" "$BASE" --tag M99); rc=$?
assert_exit "--tag M99 + bogus M33 quote exits 1" 1 "$rc" "$out" ""
assert_contains "--tag M99 + bogus M33 quote names unquoted" "unquoted deletions" "$out"
# ---- Test 12: bad --base (exit 2) --------------------------------------- # ---- Test 12: bad --base (exit 2) ---------------------------------------
echo "=== bad --base ===" echo "=== bad --base ==="
T="$SCRATCH/t12"; mk_repo "$T" T="$SCRATCH/t12"; mk_repo "$T"
@@ -286,6 +301,92 @@ out=$(python3 "$SCRIPT" --help); rc=$?
assert_exit "--help exits 0" 0 "$rc" "$out" "" assert_exit "--help exits 0" 0 "$rc" "$out" ""
assert_contains "--help shows usage" "spec-revocation-check" "$out" assert_contains "--help shows usage" "spec-revocation-check" "$out"
assert_contains "--help mentions --base" "--base" "$out" assert_contains "--help mentions --base" "--base" "$out"
assert_contains "--help mentions --strict" "--strict" "$out"
# ---- Tests 16-19: real-case fixture (round 2) -------------------------
# One base file, one current file with four marker styles:
# - (M33) tagged quote (valid)
# - Older untagged mid-line marker (M32-era, M23 amendment note)
# - (M23) tagged quote
# - Free-prose `**Revokes prior text:**` line
# Each marker covers a real base line. With --tag M33 the foreign markers
# must be ignored; the free-prose line must be ignored unless --strict.
echo "=== --tag scopes marker parsing to the literal tag ==="
T="$SCRATCH/t16"; mk_repo "$T"
# Base: a stable line + one line per marker style + a short line that
# Check B skips (under min-line-chars=12). The short line is what the
# free-prose marker refers to (so Check B won't flag its deletion).
commit_file "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nLine revoked by M33.\n\nLine revoked by untagged mid-line.\n\nLine revoked by M23.\n\nshort text\n' >/dev/null
BASE=$(git -C "$T" rev-parse HEAD)
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "Line revoked by M33."\n\n*(Amended M23: ... — Revokes prior text: "Line revoked by untagged mid-line.".)*\n\nRevokes prior text (M23): "Line revoked by M23."\n\n> **Revokes prior text:** the short text\n'
# Sub-case 1: --tag M33 — only the (M33) line is parsed; foreign markers
# are ignored entirely. The base lines they refer to are still verbatim
# in the current file (inside the ignored marker text), so Check B
# passes. Clean.
out=$(run_check "$T" "$BASE" --tag M33); rc=$?
assert_exit "--tag M33 ignores foreign markers exits 0" 0 "$rc" "$out" ""
assert_contains "--tag M33 ignores foreign says clean" "clean" "$out"
# Sub-case 2: malformed (M33) line with --tag M33 — flagged. The other
# foreign markers must STILL be ignored (not malformed).
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33) no quote\n\n*(Amended M23: ... — Revokes prior text: "Line revoked by untagged mid-line.".)*\n\nRevokes prior text (M23): "Line revoked by M23."\n\n> **Revokes prior text:** the short text\n'
out=$(run_check "$T" "$BASE" --tag M33); rc=$?
assert_exit "malformed (M33) line exits 1" 1 "$rc" "$out" ""
assert_contains "malformed (M33) under header" "malformed markers" "$out"
assert_contains "malformed (M33) names the raw line" "no quote" "$out"
# Restore the valid fixture for sub-cases 3 and 4.
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "Line revoked by M33."\n\n*(Amended M23: ... — Revokes prior text: "Line revoked by untagged mid-line.".)*\n\nRevokes prior text (M23): "Line revoked by M23."\n\n> **Revokes prior text:** the short text\n'
# Sub-case 3: no --tag — foreign markers parse (untagged branch + M23
# tagged branch); the free-prose line is silently ignored by default.
# Output: zero violations but a summary line that counts the ignored
# marker (NOT the early "clean — every line survives" message, since
# ignored > 0).
out=$(run_check "$T" "$BASE"); rc=$?
assert_exit "no --tag, free-prose ignored, exits 0" 0 "$rc" "$out" ""
assert_contains "no --tag, free-prose ignored, shows summary line" "Summary:" "$out"
assert_contains "no --tag, free-prose ignored, summary shows ignored count" "1 ignored" "$out"
# Sub-case 4: no --tag --strict — free-prose becomes malformed.
out=$(run_check "$T" "$BASE" --strict); rc=$?
assert_exit "no --tag --strict, free-prose malformed exits 1" 1 "$rc" "$out" ""
assert_contains "no --tag --strict, free-prose under header" "malformed markers" "$out"
# ---- Round-2 dedicated negative fixtures --------------------------------
# The negative-test proof loop below needs fixtures that exit 1
# consistently (not the ones above, whose state mutates between sub-
# cases). t17 is a malformed-(M33) fixture, t19 is a free-prose line with
# --strict; both should fail when run against the synthetic no-op.
# ---- Test 17 (negative): malformed (M33) line with --tag M33 ------------
echo "=== negative: malformed (M33) line with --tag M33 ==="
T="$SCRATCH/t17"; mk_repo "$T"
commit_file "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null
BASE=$(git -C "$T" rev-parse HEAD)
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nRevokes prior text (M33) no quote\n\n'
out=$(run_check "$T" "$BASE" --tag M33); rc=$?
assert_exit "negative t17 malformed (M33) exits 1" 1 "$rc" "$out" ""
assert_contains "negative t17 malformed (M33) under header" "malformed markers" "$out"
# ---- Test 19 (negative): free-prose line with no --tag --strict ---------
echo "=== negative: free-prose with no --tag --strict ==="
T="$SCRATCH/t19"; mk_repo "$T"
commit_file "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\nshort text\n' >/dev/null
BASE=$(git -C "$T" rev-parse HEAD)
write_file_via_py "$T" "spec/foo.md" \
$'# Spec\n\nA line that stays.\n\n> **Revokes prior text:** the short text\n'
out=$(run_check "$T" "$BASE" --strict); rc=$?
assert_exit "negative t19 free-prose strict exits 1" 1 "$rc" "$out" ""
assert_contains "negative t19 free-prose strict under header" "malformed markers" "$out"
# ---- Negative-test verification (would each negative fail if disabled?) - # ---- Negative-test verification (would each negative fail if disabled?) -
# A "negative" test would PASS against a no-op script that always exits 0. # A "negative" test would PASS against a no-op script that always exits 0.
@@ -317,7 +418,7 @@ sys.exit(0)
PYEOF PYEOF
chmod +x "$SCRATCH/nop.py" chmod +x "$SCRATCH/nop.py"
for case in t2 t3 t4 t6 t7; do for case in t2 t3 t4 t6 t7 t17 t19; do
T="$SCRATCH/$case" T="$SCRATCH/$case"
BASE=$(git -C "$T" rev-parse HEAD 2>/dev/null || echo "HEAD") BASE=$(git -C "$T" rev-parse HEAD 2>/dev/null || echo "HEAD")
out_nop=$(python3 "$SCRATCH/nop.py" --base HEAD --repo-root "$T" --glob "spec/**/*.md" 2>&1); rc_nop=$? out_nop=$(python3 "$SCRATCH/nop.py" --base HEAD --repo-root "$T" --glob "spec/**/*.md" 2>&1); rc_nop=$?