#!/usr/bin/env python3 """Заменить дублирующий реализации на (код-стайл Дейла). Консервативно: * собираются тексты членов интерфейсов (блоки, состоящие только из ); * в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// `; * блоки с ///... и 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"", re.IGNORECASE) SUMMARY_CLOSE_RE = re.compile(r"", 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}/// ") 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())