Добавить 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__.
This commit is contained in:
Rustam Khalimov
2026-09-11 18:19:46 +03:00
parent f4737b9797
commit dde86fd63c
4 changed files with 200 additions and 6 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/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())