cln/devtools/bolt-coverage.py
Rusty Russell 63aa76501a check_quotes.py: add --coverage tracking; add devtools/bolt-coverage.py.
check_quotes.py gains --coverage=FILE: on each successful match, atomically
appends one line '{bolt} {section_idx} {start} {end}' to FILE using a single
os.write() call so parallel make invocations don't interleave records.

find_quote() and find_quote_immediate() are updated to return match start
positions (needed to record the covered range, not just the end).

bolt-coverage.py reads the coverage file and reports BOLT text not covered
by any source comment.  By default it restricts output to Requirements
sections; --all-sections shows every section.  --bolt N restricts to
a single BOLT number.

Exit status is 0 if everything in the selected sections is covered, 1 if
anything is uncovered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:17:43 +09:30

278 lines
9.6 KiB
Python
Executable file

#!/usr/bin/env python3
"""Report BOLT text (by default, Requirements sections) not quoted by any source comment.
Coverage data is produced by check_quotes.py --coverage=FILE; this tool reads
that file and highlights text that no comment quotes, showing adjacent quoted
text for context.
Output is in compiler-error format so Emacs/Vim can navigate directly:
src/foo.c:13:...text covered just before the gap...
.tmp.lightningrfc/02-peer-protocol.md:5:uncovered text here
src/bar.c:99:text covered just after the gap...
Usage example:
make check-requirements-coverage
"""
import glob
import re
import sys
from argparse import ArgumentParser
from collections import defaultdict
from typing import Dict, List, Tuple
whitespace_re = re.compile(r"\s+")
def collapse_whitespace(string: str) -> str:
return whitespace_re.sub(" ", string)
def collapse_with_linemap(raw_lines: List[Tuple[int, str]]) -> Tuple[str, List[int]]:
"""Collapse whitespace across a list of (lineno, text) pairs.
Returns (collapsed_text, linemap) where linemap[i] is the original line
number for character i in collapsed_text. Matches the behaviour of
collapse_whitespace() so positions are compatible with coverage records.
"""
result: List[str] = []
linemap: List[int] = []
in_ws = False
ws_lineno = 1
for lineno, line in raw_lines:
for ch in line:
if whitespace_re.match(ch):
if not in_ws:
in_ws = True
ws_lineno = lineno
else:
if in_ws:
result.append(" ")
linemap.append(ws_lineno)
in_ws = False
result.append(ch)
linemap.append(lineno)
if in_ws:
result.append(" ")
linemap.append(ws_lineno)
return "".join(result), linemap
def load_bolt(boltdir: str, num: int) -> Tuple[str, List[Tuple[str, List[int]]]]:
"""Load a BOLT file, split into sections.
Returns (boltpath, sections) where sections is a list of
(collapsed_text, linemap) pairs; linemap[i] is the original line number
for collapsed_text[i].
"""
boltfile = glob.glob("{}/{}-*md".format(boltdir, str(num).zfill(2)))
if not boltfile:
print("Cannot find bolt {} in {}".format(num, boltdir), file=sys.stderr)
sys.exit(1)
if len(boltfile) > 1:
print("More than one bolt {} in {}? {}".format(num, boltdir, boltfile),
file=sys.stderr)
sys.exit(1)
boltpath = boltfile[0]
with open(boltpath) as f:
raw = list(enumerate(f.readlines(), 1)) # [(lineno, line), ...]
# Split into sections on lines that start with '#'.
raw_sections: List[List[Tuple[int, str]]] = []
cur: List[Tuple[int, str]] = []
for lineno, line in raw:
if line.startswith("#"):
raw_sections.append(cur)
cur = []
cur.append((lineno, line))
raw_sections.append(cur)
sections = [collapse_with_linemap(s) for s in raw_sections]
return boltpath, sections
def is_requirements_section(text: str) -> bool:
"""True if the section's header names a Requirements section."""
return bool(re.match(r"#+\s+requirements\b", text.lstrip(), re.IGNORECASE))
# Coverage record: (section_idx, start, end, src_file, src_line)
CovRecord = Tuple[int, int, int, str, int]
def load_coverage(coverage_file: str) -> Dict[int, List[CovRecord]]:
"""Return {bolt: [CovRecord, ...]} from the coverage file."""
coverage: Dict[int, List[CovRecord]] = defaultdict(list)
try:
with open(coverage_file) as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 6:
print("{}:{}: bad coverage record (expected 6 fields): {!r}".format(
coverage_file, lineno, line), file=sys.stderr)
continue
bolt = int(parts[0])
si, start, end, src_line = int(parts[1]), int(parts[2]), int(parts[3]), int(parts[5])
src_file = parts[4]
coverage[bolt].append((si, start, end, src_file, src_line))
except FileNotFoundError:
print("Coverage file not found: {}".format(coverage_file), file=sys.stderr)
sys.exit(1)
return coverage
def merge_intervals(intervals: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
"""Merge overlapping/adjacent [start, end) intervals."""
merged: List[List[int]] = []
for start, end in sorted(intervals):
if merged and start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return [(s, e) for s, e in merged]
def section_content_start(text: str) -> int:
"""Offset past the leading '### SectionTitle ' header."""
m = re.match(r"(#+\s+\S+\s+)", text.lstrip())
return m.end() if m else 0
def adjacent_before(records: List[CovRecord], gap_start: int) -> List[CovRecord]:
"""All records whose covered range ends closest to (but not after) gap_start."""
candidates = [r for r in records if r[2] <= gap_start]
if not candidates:
return []
max_end = max(r[2] for r in candidates)
return [r for r in candidates if r[2] == max_end]
def adjacent_after(records: List[CovRecord], gap_end: int) -> List[CovRecord]:
"""All records whose covered range starts closest to (but not before) gap_end."""
candidates = [r for r in records if r[1] >= gap_end]
if not candidates:
return []
min_start = min(r[1] for r in candidates)
return [r for r in candidates if r[1] == min_start]
def snippet(text: str, start: int, end: int, tail: bool = False, maxlen: int = 60) -> str:
"""Return a short excerpt of text[start:end], from the tail or head."""
s = text[start:end].strip()
if tail:
return ("..." + s[-maxlen:]) if len(s) > maxlen else ("..." + s)
return (s[:maxlen] + "...") if len(s) > maxlen else (s + "...")
def show_gaps(boltpath: str, bolt_num: int, si: int,
text: str, linemap: List[int],
section_records: List[CovRecord]) -> bool:
"""Print uncovered gaps in text with adjacent-mention context.
Returns True if any uncovered text was found.
"""
content_start = section_content_start(text)
# Build merged intervals to find gaps, but keep raw records for adjacency.
merged = merge_intervals([(r[1], r[2]) for r in section_records])
# Walk the merged intervals and find gaps.
any_gap = False
pos = content_start
for m_start, m_end in merged:
if m_start > pos:
gap_text = text[pos:m_start].strip()
if not gap_text:
pos = max(pos, m_end)
continue
any_gap = True
bolt_lineno = linemap[pos] if pos < len(linemap) else 1
before = adjacent_before(section_records, pos)
after = adjacent_after(section_records, m_start)
for r in before:
print("{}:{}:{}".format(r[3], r[4], snippet(text, r[1], r[2], tail=True)))
print("{}:{}:{}".format(boltpath, bolt_lineno, gap_text[:120]))
for r in after:
print("{}:{}:{}".format(r[3], r[4], snippet(text, r[1], r[2], tail=False)))
pos = max(pos, m_end)
# Tail gap after last interval.
if pos < len(text):
gap_text = text[pos:].strip()
if gap_text:
any_gap = True
bolt_lineno = linemap[pos] if pos < len(linemap) else 1
before = adjacent_before(section_records, pos)
for r in before:
print("{}:{}:{}".format(r[3], r[4], snippet(text, r[1], r[2], tail=True)))
print("{}:{}:{}".format(boltpath, bolt_lineno, gap_text[:120]))
return any_gap
def main() -> None:
parser = ArgumentParser(
description="Show BOLT text not covered by any source code comment"
)
parser.add_argument(
"--boltdir", default="../lightning-rfc",
help="Directory containing BOLT spec files (default: ../lightning-rfc)"
)
parser.add_argument(
"--coverage", required=True, metavar="FILE",
help="Coverage file produced by check_quotes.py --coverage=FILE"
)
parser.add_argument(
"--bolt", type=int, action="append", dest="bolts", metavar="N",
help="Restrict to BOLT #N (may be repeated; default: all bolts in coverage file)"
)
parser.add_argument(
"--all-sections", action="store_true",
help="Check all sections, not just Requirements sections"
)
args = parser.parse_args()
coverage = load_coverage(args.coverage)
bolts_to_check = sorted(args.bolts if args.bolts else coverage.keys())
any_uncovered = False
for bolt_num in bolts_to_check:
try:
boltpath, sections = load_bolt(args.boltdir, bolt_num)
except SystemExit:
continue
# Group records by section index.
by_section: Dict[int, List[CovRecord]] = defaultdict(list)
for rec in coverage.get(bolt_num, []):
si = rec[0]
if 0 <= si < len(sections):
by_section[si].append(rec)
for si, (text, linemap) in enumerate(sections):
if not text.strip():
continue
if not args.all_sections and not is_requirements_section(text):
continue
records = by_section.get(si, [])
if show_gaps(boltpath, bolt_num, si, text, linemap, records):
any_uncovered = True
sys.exit(1 if any_uncovered else 0)
if __name__ == "__main__":
main()