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
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 ---------------------------------------
# The two-branch no-tag pattern. We use named groups to disambiguate which
@@ -207,18 +227,42 @@ def find_quotes_and_malformed(
path: Path,
marker_re: re.Pattern[str],
no_tag_mode: bool,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
"""Return (quotes, malformed).
tag: str | None,
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.
``malformed``: [(line_no, raw_line), ...] for every line containing the
phrase ``Revokes prior text`` that did NOT parse.
``quotes``: ``[(line_no, quoted_text), ...]`` for every parseable marker.
``malformed``: ``[(line_no, raw_line), ...]`` for every candidate line
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]] = []
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):
if "Revokes prior text" not in line:
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)
if m:
if no_tag_mode:
@@ -230,8 +274,14 @@ def find_quotes_and_malformed(
q = m.group(1)
quotes.append((n, q or ""))
else:
malformed.append((n, line))
return quotes, malformed
if no_tag_mode and is_free_prose_unquoted(line):
if strict:
malformed.append((n, line))
else:
ignored += 1
else:
malformed.append((n, line))
return quotes, malformed, ignored
# --- The check itself -----------------------------------------------------
@@ -243,6 +293,7 @@ def run_checks(
marker_re: re.Pattern[str],
globs: list[str],
min_line_chars: int,
strict: bool,
) -> dict:
files_at_head = iter_files(repo_root, globs)
renames = git_diff_renames(repo_root, base_sha)
@@ -255,6 +306,7 @@ def run_checks(
unquoted: list[dict] = []
fabricated: list[dict] = []
malformed: list[dict] = []
ignored_total = 0
# Pass 1: every file at HEAD (Check A + Check B).
for path in files_at_head:
@@ -265,7 +317,10 @@ def run_checks(
old_rel = rename_targets[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:
# 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()),
"base": base_sha,
"tag": tag,
"strict": strict,
"marker_regex": marker_re.pattern,
"files_checked": sorted(head_rel_set),
"violations": {
@@ -355,6 +411,7 @@ def run_checks(
"fabricated": len(fabricated),
"malformed": len(malformed),
"deleted": len(deleted),
"ignored": ignored_total,
},
}
@@ -387,7 +444,7 @@ def format_human(result: dict) -> str:
for x in v["deleted_since_base"]:
lines.append(f" {x['path']} ({x['base']})")
if not any(v.values()):
if not any(v.values()) and result["summary"]["ignored"] == 0:
return (
f"spec-revocation-check: clean — every {result['base']} line "
"survives or is quoted."
@@ -397,7 +454,8 @@ def format_human(result: dict) -> str:
lines.append(
f"Summary: {s['unquoted'] + s['fabricated'] + s['malformed'] + s['deleted']} "
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)
@@ -435,6 +493,9 @@ def main(argv: list[str]) -> int:
help="override the marker regex; must have exactly one capture group")
parser.add_argument("--min-line-chars", type=int, 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",
help="emit result as JSON on stdout")
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] base: {args.base} -> {base_sha}")
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] min_line_chars: {args.min_line_chars}")
print(f"[dryrun] files: {len(head_files)}")
@@ -504,6 +566,7 @@ def main(argv: list[str]) -> int:
marker_re=marker_re,
globs=args.globs,
min_line_chars=args.min_line_chars,
strict=args.strict,
)
if args.json: