- сервис поднимается через `task up`/`task restart`, данные вынесены в ./data и переживают пересборку — поток с телефона не прерывается - `task gate` гоняет build/vet/lint/test/race/покрытие диффа/миграции, плюс два шага под healthlog: данные о здоровье в индексе и синхронность образцов конфига - `task review:context` собирает вход для архитектурного прохода ревью
113 lines
3.9 KiB
Python
Executable File
113 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Покрытие изменённых строк тестами.
|
|
|
|
Складывает `go test -coverprofile` и `git diff -U0 <base>`: показывает, какие
|
|
изменённые исполняемые строки не покрыты ни одним тестом. Общий процент по
|
|
пакету бесполезен для ревью — важно, покрыт ли именно новый код.
|
|
|
|
Использование: scripts/diff-coverage.py <coverprofile> <base-rev>
|
|
"""
|
|
|
|
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())
|