Files
Deal/scripts/dedup_summary_inheritdoc.py
T
Rustam Khalimov dde86fd63c Добавить codemod дедупликации summary и уточнить бэклог
scripts/dedup_summary_inheritdoc.py (dry-run не нашёл простых дублей summary в продакшне — реализации уже на <inheritdoc/>); закрыт TD-SETTINGS-UI, DEFERRED с обоснованием TD-CARD-MERGE/TD-VIRT/TD-STORE-ATTACH/TD-SOURCE-CONTACTS; игнор __pycache__.
2026-09-11 18:19:46 +03:00

191 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
Консервативно:
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
Без --apply — dry-run со счётчиками.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ROOTS = [
REPO / "src" / "core",
REPO / "src" / "telegram-service",
REPO / "src" / "ai-service",
REPO / "src" / "ml-service",
REPO / "src" / "storage-service",
REPO / "src" / "grpc-hosting",
]
INTERFACE_RE = re.compile(
r"^\s*(?:public|internal|protected|private)?\s*"
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
)
TYPE_BASE_RE = re.compile(
r"^\s*(?:public|internal|protected|private)?\s*"
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
)
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
def iter_files() -> list[Path]:
files: list[Path] = []
for root in ROOTS:
if not root.exists():
continue
for path in root.rglob("*.cs"):
parts = set(path.parts)
if "obj" in parts or "bin" in parts:
continue
if "tests" in path.as_posix().lower():
continue
files.append(path)
return sorted(files)
def normalize(block: list[str]) -> str:
text = " ".join(line.strip().lstrip("/").strip() for line in block)
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
return re.sub(r"\s+", " ", text).strip()
def is_summary_only(block: list[str]) -> bool:
joined = "\n".join(block)
return (
bool(SUMMARY_OPEN_RE.search(joined))
and bool(SUMMARY_CLOSE_RE.search(joined))
and not EXTRA_TAG_RE.search(joined)
)
def depth_snapshot(lines: list[str]) -> list[int]:
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
depths: list[int] = []
depth = 0
for line in lines:
depths.append(depth)
depth += line.count("{") - line.count("}")
return depths
def type_scopes(lines: list[str]):
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
"""
depths = depth_snapshot(lines)
scopes: list[tuple[int, int, str]] = []
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
for idx, line in enumerate(lines):
depth_before = depths[idx]
if INTERFACE_RE.match(line):
stack.append((depth_before, "interface", idx))
else:
m = TYPE_BASE_RE.match(line)
if m and IFACE_NAME_RE.search(m.group(1)):
stack.append((depth_before, "type", idx))
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
while (
stack
and depths[idx] <= stack[-1][0]
and lines[idx].strip().startswith("}")
and idx > stack[-1][2]
):
depth, kind, start = stack.pop()
scopes.append((start, idx, kind))
for depth, kind, start in stack:
scopes.append((start, len(lines) - 1, kind))
return scopes
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
kinds: set[str] = set()
for start, end, kind in scope_kinds:
if start <= line_idx <= end and start != line_idx:
kinds.add(kind)
return kinds
def collect_interface_summaries(files: list[Path]) -> set[str]:
summaries: set[str] = set()
for path in files:
lines = path.read_text(encoding="utf-8").splitlines()
depths = depth_snapshot(lines)
scopes = type_scopes(lines)
idx = 0
while idx < len(lines):
if not lines[idx].lstrip().startswith("///"):
idx += 1
continue
start = idx
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
idx += 1
block = lines[start:idx]
kinds = containing_kinds(scopes, start, depths[start])
if "interface" in kinds and is_summary_only(block):
summaries.add(normalize(block))
return summaries
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
lines = path.read_text(encoding="utf-8").splitlines()
depths = depth_snapshot(lines)
scopes = type_scopes(lines)
result: list[str] = []
replaced = 0
idx = 0
while idx < len(lines):
if not lines[idx].lstrip().startswith("///"):
result.append(lines[idx])
idx += 1
continue
start = idx
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
idx += 1
block = lines[start:idx]
kinds = containing_kinds(scopes, start, depths[start])
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
result.append(f"{indent}/// <inheritdoc />")
replaced += 1
else:
result.extend(block)
return replaced, result
def main() -> int:
apply = "--apply" in sys.argv
files = iter_files()
iface_summaries = collect_interface_summaries(files)
total = 0
changed = 0
for path in files:
replaced, new_lines = process_file(path, iface_summaries)
if not replaced:
continue
total += replaced
changed += 1
if apply:
original = path.read_text(encoding="utf-8")
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
path.write_text(text, encoding="utf-8")
mode = "применено" if apply else "dry-run"
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
return 0
if __name__ == "__main__":
raise SystemExit(main())