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:
parent
3e1d4bf1eb
commit
65683059fc
2 changed files with 102 additions and 70 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue