fix(spec-revocation-check): --tag scopes marker parsing; free-prose ignored by default (brief)
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user