mirror of
https://github.com/ElementsProject/lightning.git
synced 2026-08-13 12:32:55 +02:00
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>
278 lines
9.6 KiB
Python
Executable file
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()
|