diff --git a/docs/scripts/counts.py b/docs/scripts/counts.py index f1298698..cb8677c9 100644 --- a/docs/scripts/counts.py +++ b/docs/scripts/counts.py @@ -283,7 +283,10 @@ def touched_lines() -> dict[str, set[int]]: """Путь → номера строк, ДОБАВЛЕННЫХ этим коммитом (сторона документа для дельта-сверки).""" global _TOUCHED if _TOUCHED is None: - args = ["git", "diff", "-U0"] + (["--cached"] if FROM_INDEX else ["HEAD"]) + # ⚠ Префиксы форсируем: при пользовательском `diff.noprefix=true` разбор `+++ b/` молча + # вернул бы пусто, и сторона документа умерла бы БЕЗ ЗВУКА — худший класс отказа гейта. + args = ["git", "diff", "-U0", "--src-prefix=a/", "--dst-prefix=b/"] + ( + ["--cached"] if FROM_INDEX else ["HEAD"]) out = subprocess.run(args, cwd=ROOT, capture_output=True, text=True).stdout acc: dict[str, set[int]] = {} rel = None @@ -300,14 +303,21 @@ def touched_lines() -> dict[str, set[int]]: _TOUCHED: dict[str, set[int]] | None = None -LINT_PREFIXES = [ +LINT_PREFIXES_RAW = [ "", "backend/", "backend/internal/", "backend/cmd/", "backend/configs/", "backend/internal/chunk/", "platform/", "platform/internal/", "platform/cmd/", "platform/docs/", "frontend/", "frontend/docs/", "frontend/src/", "docs/", "docs/architecture/", "eval/", ] # Обзоры ЧУЖОГО кода: их якоря указывают в сторонние репо (litellm/crush/openai-go…) и в нашем # дереве не резолвятся по построению — не линтим. -REPO_TOPS = {p.name for p in ROOT.iterdir() if p.is_dir() and not p.name.startswith(".")} +# ⚠ ЗАМОРОЖЕНО литералом, а не выведено из `iterdir()` (разбор 21.08): рантаймовое множество меняется +# ВМЕСТЕ с болезнью — переименуй каталог, и все его якоря в тот же коммит тихо станут «внешними» +# ровно тогда, когда нужен крик. Плюс случайный untracked-каталог в корне расширял бы фильтр. +REPO_TOPS = {"backend", "docs", "eval", "frontend", "platform"} +# Первые сегменты ЗОННО-ОТНОСИТЕЛЬНЫХ якорей (`internal/pgstore/…`, `cmd/…`) — их в корпусе больше +# пятнадцати, и без этого множества сломавшийся зонный якорь молча переклассифицировался бы во +# «внешнюю ссылку» вместо «файла нет». +ZONE_TOPS = {pre.split("/", 1)[0] for pre in LINT_PREFIXES_RAW if "/" in pre} LINT_SKIP_DOCS = { "docs/research/21-llm-transport-survey.md", "docs/research/26-anthropic-guidance-digest.md", @@ -345,29 +355,57 @@ def doc_text(rel: str) -> str | None: return got.stdout if got.returncode == 0 else None -def lint_anchors() -> tuple[list[str], dict[str, int]]: - """Якорный линт с ДВУСТОРОННЕЙ дельта-сверкой. +def norm(text: str) -> str: + """Пробельная нормализация — и только она. - ⚠ Почему двусторонняя, а не «только затронутые строки» (разбор 21.08, строка 205): якорь протухает, - когда двигается ЦЕЛЬ, а не носитель. Четыре якоря, сломанных архивацией 20.08, лежали в строках, - которых тот коммит не касался вовсе — односторонний гейт прошёл бы свой же породивший инцидент - ЗЕЛЁНЫМ. Поэтому содержимое сверяется, если (а) строка с якорем затронута коммитом ЛИБО (б) файл-ЦЕЛЬ - входит в состав коммита. - - ⚠ Сверка содержимого возможна только у якорей с ОПТ-ИН токеном (`путь:123`=`подстрока`). Гадать по - окружающей прозе гейт не имеет права: угадывающий врёт в обе стороны, а хук, который врёт, учат - обходить. Токен ТРЕБУЕТСЯ только на затронутых строках — так корпус мигрирует по мере касания, а - пре-существующий долг никого не блокирует. + ⚠ Разбор 21.08: схлопывание пробелов и переносов ДЕТЕРМИНИРОВАНО и симметрично, это не шаг к + угадыванию. Без него фраза, разорванная переносом строки в цели, непроверяема в принципе (док + сканируется построчно, токен не может нести `\n`), а каждая будущая пере-вёрстка абзаца цели + ломала бы токен и кричала «цель уехала» на невиновного — ложная тревога, отложенная на чужой + коммит. РАЗМЕТКУ (`**`, бэктики) НЕ трогаем: в Go-целях `*` семантичен, и она же держит давление + «спиши из цели дословно». Первая редакция была литеральной и первой же поймала САМ ГЕЙТ на лжи: + токен был истинным, цель не двигалась, а гейт напечатал «цель уехала». """ - bad: list[str] = [] - tally = {"content": 0, "length": 0, "notoken": 0, "files": 0} + return " ".join(text.split()) + + +def anchor_window(lineno: int, tail: str) -> list[tuple[int, int]]: + """`80-82,243` → [(80,82),(243,243)]. ОБЪЕДИНЕНИЕ отрезков, а не min..max. + + ⚠ `min..max` давал окно в 164 строки на `mining.go:80-82,243`, и токен, случайно живущий где-то + внутри, красил гейт зелёным (баг реализации, пойман разбором 21.08). + """ + out = [] + for part in f"{lineno}{tail}".split(","): + a, _, b = part.partition("-") + if a.strip().isdigit(): + out.append((int(a), int(b) if b.strip().isdigit() else int(a))) + return out or [(lineno, lineno)] + + +def lint_anchors() -> tuple[list[tuple[int, str]], dict[str, int]]: + """Якорный линт: сверка содержимого по опт-ин токену + дельта-ТРЕБОВАНИЕ токена. + + ⚠ У дельты ДВЕ РАЗНЫЕ роли, и путать их нельзя (разбор 21.08, строка 205): + · **ТРЕБОВАТЬ** токен можно только на строках, которые коммит трогает — иначе пре-существующий + долг блокирует всех; корпус мигрирует по мере касания. + · **ПРОВЕРЯТЬ** уже написанные токены дельта не должна ограничивать ВООБЩЕ: токен был истинным в + момент записи, значит упавший токен — настоящая гниль по построению, проблемы «старого долга» у + него нет. Прежняя редакция сверяла токены только внутри дельты, и это схлопывало «пропущенное + окно» (коммит без хука, `--no-verify`, свежий клон без установленных хуков, проигнорированное + warn-предупреждение) из «до следующего прогона» в «навсегда». + Поэтому: токены сверяются по ВСЕМУ живому корпусу каждый прогон; дельта управляет только + требованием, и требование живёт только под `--from-index` (в ручном прогоне «дельта» = всё + незакоммиченное КЕМ УГОДНО, включая чужой WIP параллельных сессий). + """ + bad: list[tuple[int, str]] = [] # (ранг тяжести, текст) — ранг нужен для приоритета под капом + tally = {"content": 0, "notoken": 0, "external": 0, "unjudged": 0, "files": 0} targets = [ROOT / "CLAUDE.md"] + [ p for base in ("docs", "platform/docs", "frontend/docs") for p in (ROOT / base).rglob("*.md") if "archive" not in p.parts and str(p.relative_to(ROOT)) not in LINT_SKIP_DOCS ] touched = touched_lines() - in_commit = set(staged_files()) if FROM_INDEX else set(worktree_state()) line_counts: dict[Path, int] = {} body_cache: dict[Path, list[str]] = {} @@ -381,51 +419,42 @@ def lint_anchors() -> tuple[list[str], dict[str, int]]: for m in ANCHOR.finditer(line): rel, lineno, tail, token = m.group(1).lstrip("/"), int(m.group(2)), m.group(3) or "", m.group(4) shown = f"{rel}:{lineno}{tail}" - if "~" in rel or "/" not in rel or "..." in rel: - continue # ран-локальные пути, голые имена и «…»-сокращения не судим - hit = next((ROOT / pre / rel for pre in LINT_PREFIXES if (ROOT / pre / rel).is_file()), None) where = f"{doc_rel}:{n}" + if "~" in rel or "/" not in rel or "..." in rel: + tally["unjudged"] += 1 + continue # ран-локальные пути, голые имена и «…»-сокращения + hit = next((ROOT / pre / rel for pre in LINT_PREFIXES_RAW if (ROOT / pre / rel).is_file()), None) if hit is None: - # ⚠ Ссылка ВНЕ репозитория (stdlib Go `net/http/server.go`, чужой проект) — не наш - # якорь, судить нечем. Отличаем по первому сегменту: он обязан быть верхнеуровневым - # каталогом репо, иначе это внешняя ссылка. Без этого правила гейт дал три ложные - # тревоги на первом же прогоне, а ложная тревога и есть то, из-за чего гейты - # начинают обходить. - if rel.split("/", 1)[0] not in REPO_TOPS: + # ⚠ Внешняя ссылка (stdlib Go, чужой проект) — судить нечем. Но проверка обязана + # быть УЗКОЙ: зонно-относительные пути (`internal/…`, `cmd/…`) — НАШИ, и сломавшийся + # такой якорь должен кричать «файла нет», а не тихо стать «внешним». + top = rel.split("/", 1)[0] + if top not in REPO_TOPS and top not in ZONE_TOPS: + tally["external"] += 1 continue - bad.append(f"{where}: якорь `{shown}` — файла нет ни под одним из префиксов {LINT_PREFIXES}") + bad.append((1, f"{where}: якорь `{shown}` — файла нет ни под одним из префиксов")) continue if hit not in line_counts: body_cache[hit] = hit.read_text(encoding="utf-8", errors="replace").splitlines() line_counts[hit] = len(body_cache[hit]) if lineno > line_counts[hit]: - bad.append(f"{where}: якорь `{shown}` — в {hit.relative_to(ROOT)} всего {line_counts[hit]} строк") + bad.append((1, f"{where}: якорь `{shown}` — в {hit.relative_to(ROOT)} всего {line_counts[hit]} строк")) continue - hit_rel = str(hit.relative_to(ROOT)) - doc_side = n in touched.get(doc_rel, ()) - target_side = hit_rel in in_commit - if not (doc_side or target_side): - tally["length"] += 1 - continue - if token is None: - if doc_side: - tally["notoken"] += 1 - bad.append( - f"{where}: якорь `{shown}` на ЗАТРОНУТОЙ строке без токена ожидания — " - f"допишите `` =`подстрока` ``, иначе содержимое не сверяемо" - ) - else: - tally["notoken"] += 1 - continue - end = max([lineno] + [int(x) for x in re.findall(r"\d+", tail)]) - window = "\n".join(body_cache[hit][lineno - 1:end]) - if token in window: - tally["content"] += 1 - else: - bad.append( - f"{where}: якорь `{shown}` — в строках {lineno}..{end} файла " - f"{hit_rel} НЕТ ожидаемого «{token}» (цель уехала)" + if token is not None: + body = body_cache[hit] + window = " ".join( + "\n".join(body[a - 1:min(b, len(body))]) for a, b in anchor_window(lineno, tail) ) + if norm(token) in norm(window): + tally["content"] += 1 + else: + bad.append((0, f"{where}: якорь `{shown}` — ожидаемого «{token}» в цели НЕТ (цель уехала)")) + elif FROM_INDEX and n in touched.get(doc_rel, ()): + tally["notoken"] += 1 + bad.append((2, f"{where}: якорь `{shown}` на ЗАТРОНУТОЙ строке без токена ожидания — " + f"допишите `` =`подстрока` ``, иначе содержимое не сверяемо")) + else: + tally["notoken"] += 1 return bad, tally @@ -440,25 +469,27 @@ def region(text: str, name: str) -> str: def main() -> int: if "--lint" in sys.argv: problems, tally = lint_anchors() - # ⚠ Кап вывода: реструктуризация дока (нарезка слайсов, массовая переразметка) помечает - # затронутыми ВСЕ его строки и вывалила бы весь пре-существующий долг разом. Warn-only - # превращает такой залп в шум, а шум учат скроллить. + # ⚠ Кап с ПРИОРИТЕТОМ: залп из «допишите токен» на переразметке дока прятал бы за капом + # настоящие «цель уехала». Ранг: 0 — цель уехала · 1 — файла нет / номер за концом · 2 — нет токена. + problems.sort(key=lambda x: x[0]) CAP = 25 - for p in problems[:CAP]: - print(" ✗ " + p) + for _, msg in problems[:CAP]: + print(" ✗ " + msg) if len(problems) > CAP: - print(f" … ещё {len(problems) - CAP} не показано (кап вывода {CAP})") + print(f" … ещё {len(problems) - CAP} не показано (кап вывода {CAP}, сначала тяжёлые)") print(f"\n--lint: {len(problems)} проблемных якорей в {tally['files']} живых доках") - # ⚠ Норма «гейт печатает, чего он НЕ проверяет» (D39.153 п.9г): прежняя редакция молча + # ⚠ Норма «гейт печатает, чего он НЕ проверяет» (D39.153 п.9г). Прежняя редакция молча # проверяла только существование файла и переполнение номера — и дала ЗЕЛЁНЫЙ на четыре # якоря, сломанных сдвигом содержимого. Ложная уверенность от гейта дороже его дыры. print( - f" сверено ПО СОДЕРЖИМОМУ: {tally['content']} · " - f"только существование и длина файла: {tally['length']} · " - f"в дельте, но без токена ожидания — НЕсверяемы: {tally['notoken']}" + f" сверено ПО СОДЕРЖИМОМУ (токен): {tally['content']} · без токена — несверяемы: " + f"{tally['notoken']} · внешних ссылок пропущено: {tally['external']} · " + f"неразбираемых форм пропущено: {tally['unjudged']}" ) - print(" НЕ проверяется: содержимое якорей вне дельты коммита; смысловая актуальность текста;") - print(" якоря в archive/** и в доках из LINT_SKIP_DOCS.") + print(" НЕ проверяется: якорь без токена (только существование файла и длина) · подмена цели") + print(" файлом с тем же путём · смысловая актуальность текста · archive/** и LINT_SKIP_DOCS.") + print(" ⚠ Цели читаются из ДЕРЕВА (истина кода — дерево), поэтому чужой незакоммиченный WIP") + print(" в цели может транзиентно и уронить токен, и спасти его.") return 1 if problems else 0 dlog, prog, reg = read(DLOG), read(PROGRESS), read(REGISTER) diff --git a/docs/scripts/githooks/pre-commit b/docs/scripts/githooks/pre-commit index 84b99db3..35d8b8a0 100755 --- a/docs/scripts/githooks/pre-commit +++ b/docs/scripts/githooks/pre-commit @@ -39,11 +39,12 @@ staged=$(git diff --cached --name-only) dlog='docs/architecture/05-decisions-log.md' prog='docs/PROGRESS.md' -# Быстрый выход: коммит не задевает документацию — нам тут нечего делать. Триггер ШИРЕ носителей -# чисел (D39.148): якоря `file:line` живут во ВСЕХ доках, включая промты сессий, поэтому линт обязан -# видеть и промт-коммит. Полигонский фриз-коммит, задевший docs/experiments/, изредка увидит -# предупреждение — приемлемо: оно warn-only и самоописано. -printf '%s\n' "$staged" | grep -qE '^(docs/.*|CLAUDE\.md)$' || exit 0 +# ⚠ БЫСТРОГО ВЫХОДА ПО «коммит не задевает docs/» БОЛЬШЕ НЕТ (21.08, разбор с внешним ревьюером). +# Он ампутировал ЦЕЛЕВУЮ сторону дельта-сверки якорей: якорь протухает, когда двигается ЦЕЛЬ, а цели +# у нас в основном КОД — то есть главный двигатель протухания приезжал ровно тем коммитом, на котором +# хук молча выходил. Плюс расширение области линта на platform/docs и frontend/docs в старый триггер +# не попадало вовсе. Цена отмены нулевая: линт ~0.2 с и warn-only; блок производных ЧИСЕЛ по-прежнему +# гейчен составом коммита ниже (он дорогой и осмыслен только при ратификации). warn=0