Split the two roles of the anchor delta: tokens are verified corpus-wide every run, the delta only gates when a token is required, and the hook no longer skips commits that move the targets

This commit is contained in:
Claude (backend session) 2026-08-21 04:09:28 +03:00
parent 3e1d4bf1eb
commit 65683059fc
2 changed files with 102 additions and 70 deletions

View file

@ -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)

View file

@ -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