#!/usr/bin/env python3
"""Покрытие изменённых строк тестами.
Складывает `go test -coverprofile` и `git diff -U0 `: показывает, какие
изменённые исполняемые строки не покрыты ни одним тестом. Общий процент по
пакету бесполезен для ревью — важно, покрыт ли именно новый код.
Использование: scripts/diff-coverage.py
"""
import re
import subprocess
import sys
from collections import defaultdict
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
BLOCK = re.compile(r"^(.+):(\d+)\.\d+,(\d+)\.\d+ (\d+) (\d+)$")
def module_path() -> str:
with open("go.mod", encoding="utf-8") as f:
for line in f:
if line.startswith("module "):
return line.split(None, 1)[1].strip()
return ""
def coverage_blocks(profile: str, module: str):
"""file -> [(start, end, count)] по репо-относительным путям."""
blocks = defaultdict(list)
with open(profile, encoding="utf-8") as f:
for line in f:
m = BLOCK.match(line.strip())
if not m:
continue
path, start, end, _stmts, count = m.groups()
if module and path.startswith(module + "/"):
path = path[len(module) + 1:]
blocks[path].append((int(start), int(end), int(count)))
return blocks
def changed_lines(base: str):
"""file -> {номера добавленных/изменённых строк} для нетестовых .go."""
out = subprocess.run(
["git", "diff", "-U0", base, "--", "*.go"],
capture_output=True, text=True, check=True,
).stdout
changed = defaultdict(set)
current = None
for line in out.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
current = None if path.endswith("_test.go") else path
elif line.startswith("@@") and current:
m = HUNK.match(line)
if m:
start = int(m.group(1))
count = int(m.group(2) or 1)
changed[current].update(range(start, start + count))
return changed
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
profile, base = sys.argv[1], sys.argv[2]
blocks = coverage_blocks(profile, module_path())
changed = changed_lines(base)
total = uncovered = 0
report = []
for path in sorted(changed):
gaps = []
for line in sorted(changed[path]):
covering = [b for b in blocks.get(path, []) if b[0] <= line <= b[1]]
if not covering:
continue # не исполняемая строка (объявление, комментарий, скобка)
total += 1
if all(b[2] == 0 for b in covering):
uncovered += 1
gaps.append(line)
if gaps:
report.append((path, gaps))
if total == 0:
print("изменённых исполняемых строк нет (или профиль не содержит этих пакетов)")
return 0
print(f"изменённых исполняемых строк: {total}, не покрыто: {uncovered}"
f" ({100 * (total - uncovered) // total}% покрытия диффа)")
for path, gaps in report:
print(f" {path}: {compact(gaps)}")
return 0
def compact(lines):
"""[3,4,5,9] -> '3-5,9'."""
out, start, prev = [], lines[0], lines[0]
for line in lines[1:] + [None]:
if line == prev + 1:
prev = line
continue
out.append(str(start) if start == prev else f"{start}-{prev}")
start = prev = line
return ",".join(out)
if __name__ == "__main__":
sys.exit(main())