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__.
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
#!/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())
|