From 5bfa61c2728d62788b131b89871e7a6ae5f3afce Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Fri, 21 Aug 2026 03:27:23 +0300 Subject: [PATCH] Make the anchor lint verify content on a two-sided delta with opt-in expectation tokens, skip external references, cap its output and declare what it does not check --- docs/architecture/05-decisions-log.md | 2 +- docs/scripts/counts.py | 134 +++++++++++++++++++++++--- platform/docs/platform-PROGRESS.md | 2 +- 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 09ec1562..8ee3d886 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -539,7 +539,7 @@ **1. Приёмка исполнением.** Панель шести линз в изолированных копиях, каждой свой каталог, бэкап чужого незакоммиченного ДО запуска (D39.113): слепая (заказ+дифф ДО отчёта) · контракт-конформность (пофайловый дифф wire-структур против ОБЕИХ версий канона) · деньги (два независимых пути из сырого леджера + живой стенд) · шов (инвентарь ВСЕХ каналов движка чтением чужого кода) · вне карты · ревью канона 0.4.0 другой моделью. Ни одной REJECT, регрессов против HEAD нет. **Пере-проверено рукой оркестратора:** `make check` с обоими гейтами — 18 пакетов, EXIT=0, **скипов 0**, линтер 0 issues · реестр 327 строк / 65 открытых (13 minor, 52 info), major и BLOCKER нет · оба док-гейта зелёные · миграционный манифест сходится, released-миграции не тронуты · `tmplatformctl books --migratable` живой. ⚠ Тестовых функций **532**, не 533 (команда — `grep -rh '^func Test' platform --include='*_test.go' | wc -l`; норма «дифф `^func Test` исполнением, не памятью» — `docs/ORCHESTRATOR_SESSION_PROMPT.md` §кодовый промт, со ссылкой на D39.121; в теле самой D39.121 команды нет — испр. при проверке записей); удалённых имён семь — каждое проверено, у каждого есть преемник, и преемники СИЛЬНЕЕ либо перевёрнуты по ратифицированному решению (гейт подписи банка → `TestResumeLiftsABankStopWithTheDecisionsAsTheyStand` плюс НОВЫЙ встречный `TestTheReconcilerDoesNotLiftABankStopNobodySigned`). -**2. Замер, обосновавший разворот схемного решения ВНУТРИ пака (00016 → 00022), пере-выведен независимо.** Свой бенчмарк на трёх вариантах в копии дерева: джойн **14.6 мс** · счётчик **5.3 мс** · без счётчика **2.9 мс**. Порядок и вывод подтверждаются, разворот законен: 00016 снёс колонку с доводом «её никто не пишет», 00022 вернул её ВМЕСТЕ с писателем. ⚠ Процент назван ЧЕТЫРЬМЯ носителями (журнал зоны 83% · `platform/internal/pgstore/perf_test.go:13` 96% · миграция 00022 «16.8 против 3.4, джойн сам 9 мс» · регистр PD-306 повторяет 83% и 16.8/3.4) — распад нормы «один носитель на факт» (D39.112) внутри одного пака. ⚠ **Первая редакция этой ноты писала «96% не выводится ни из чего» — ЭТО НЕВЕРНО и снято проверкой записей при лендинге:** число выводится точно, просто из ДРУГОГО замера — `platform/docs/archive/P7_ACT5_FIX_PLAN_2026-08-20.md:444-446`, «636 мс против 24 мс» на холодном корпусе акта 4, (636−24)/636 = 96.2%. То есть носители меряли разное (холодный корпус против вакуумированного) и оба честны; сводить их надо указанием УСЛОВИЙ замера, а не удалением одного как фантома — иначе следующая сессия снесёт верное число. +**2. Замер, обосновавший разворот схемного решения ВНУТРИ пака (00016 → 00022), пере-выведен независимо.** Свой бенчмарк на трёх вариантах в копии дерева: джойн **14.6 мс** · счётчик **5.3 мс** · без счётчика **2.9 мс**. Порядок и вывод подтверждаются, разворот законен: 00016 снёс колонку с доводом «её никто не пишет», 00022 вернул её ВМЕСТЕ с писателем. ⚠ Процент назван ЧЕТЫРЬМЯ носителями (журнал зоны 83% · `platform/internal/pgstore/perf_test.go:13`=`96% of the page` 96% · миграция 00022 «16.8 против 3.4, джойн сам 9 мс» · регистр PD-306 повторяет 83% и 16.8/3.4) — распад нормы «один носитель на факт» (D39.112) внутри одного пака. ⚠ **Первая редакция этой ноты писала «96% не выводится ни из чего» — ЭТО НЕВЕРНО и снято проверкой записей при лендинге:** число выводится точно, просто из ДРУГОГО замера — `platform/docs/archive/P7_ACT5_FIX_PLAN_2026-08-20.md:444-446`=`636 мс против`, «636 мс против 24 мс» на холодном корпусе акта 4, (636−24)/636 = 96.2%. То есть носители меряли разное (холодный корпус против вакуумированного) и оба честны; сводить их надо указанием УСЛОВИЙ замера, а не удалением одного как фантома — иначе следующая сессия снесёт верное число. **3. Собственные посадки ВНЕ списка автора — 8, поймано 7.** Долг не поставлен третьей концовкой · `edit_wave` не монотонен · снят `Vary` · HEAD лишён валидатора · `reopen` снимает стоп банка кому угодно · погашение долга без сверки метки · вынос метки долга из закрывающей транзакции во второй оператор (техника `xmin` из их §36 работает — проверено исполнением, а не принято на слово). **НЕ поймано:** снятие `structure_version` И `revision` из `emitFrame` проходит ВСЮ батарею — два поля, которые канон требует на КАЖДОМ кадре `EventBase`. По норме самой зоны (PD-1) свойство без пинящего теста считается незакрытым. diff --git a/docs/scripts/counts.py b/docs/scripts/counts.py index 9ef5bc55..f1298698 100644 --- a/docs/scripts/counts.py +++ b/docs/scripts/counts.py @@ -268,14 +268,46 @@ def index_completeness() -> list[str]: # --lint: file:line-якоря живых доков. Проверяется СУЩЕСТВОВАНИЕ файла и что строка ≤ длины файла — # это ловит переименованные/удалённые файлы и грубое протухание; содержание строки механически # не проверить. Голые имена без «/» (miner.go:123) не резолвятся и не проверяются. -ANCHOR = re.compile(r"([~A-Za-z0-9_\-./]+\.(?:go|py|md|ya?ml|txt|puml|json|sql|js|ts)):(\d+)") +# ⚠ Якорь = путь + номер, плюс ДВЕ опциональные части, обе появились по разбору 21.08 (строка 205): +# хвост диапазона (`mining.go:80-82,243` — «токен в пределах диапазона», иначе гейт врёт на легитимной +# форме) и ОПТ-ИН ТОКЕН ОЖИДАНИЯ `` `путь:123`=`подстрока` ``. Токен нужен потому, что сам по себе +# `file:line` носителя ожидания НЕ несёт: сверять «содержимое» эвристикой по окружающей прозе значит +# врать в обе стороны, а хук, который врёт, учат игнорировать (докстринг ниже). +ANCHOR = re.compile( + r"([~A-Za-z0-9_\-./]+\.(?:go|py|md|ya?ml|txt|puml|json|sql|js|ts)):(\d+)((?:[-,]\d+)*)" + r"`?(?:=`([^`]+)`)?" +) + + +def touched_lines() -> dict[str, set[int]]: + """Путь → номера строк, ДОБАВЛЕННЫХ этим коммитом (сторона документа для дельта-сверки).""" + global _TOUCHED + if _TOUCHED is None: + args = ["git", "diff", "-U0"] + (["--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 + for line in out.splitlines(): + if line.startswith("+++ b/"): + rel = line[6:] + elif line.startswith("@@") and rel: + m = re.search(r"\+(\d+)(?:,(\d+))?", line) + if m: + start, count = int(m.group(1)), int(m.group(2) or 1) + acc.setdefault(rel, set()).update(range(start, start + count)) + _TOUCHED = acc + return _TOUCHED + + +_TOUCHED: dict[str, set[int]] | None = None LINT_PREFIXES = [ "", "backend/", "backend/internal/", "backend/cmd/", "backend/configs/", "backend/internal/chunk/", "platform/", "platform/internal/", "platform/cmd/", "platform/docs/", - "frontend/", "frontend/docs/", "docs/", "docs/architecture/", "eval/", + "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(".")} LINT_SKIP_DOCS = { "docs/research/21-llm-transport-survey.md", "docs/research/26-anthropic-guidance-digest.md", @@ -313,32 +345,88 @@ def doc_text(rel: str) -> str | None: return got.stdout if got.returncode == 0 else None -def lint_anchors() -> list[str]: - bad = [] +def lint_anchors() -> tuple[list[str], dict[str, int]]: + """Якорный линт с ДВУСТОРОННЕЙ дельта-сверкой. + + ⚠ Почему двусторонняя, а не «только затронутые строки» (разбор 21.08, строка 205): якорь протухает, + когда двигается ЦЕЛЬ, а не носитель. Четыре якоря, сломанных архивацией 20.08, лежали в строках, + которых тот коммит не касался вовсе — односторонний гейт прошёл бы свой же породивший инцидент + ЗЕЛЁНЫМ. Поэтому содержимое сверяется, если (а) строка с якорем затронута коммитом ЛИБО (б) файл-ЦЕЛЬ + входит в состав коммита. + + ⚠ Сверка содержимого возможна только у якорей с ОПТ-ИН токеном (`путь:123`=`подстрока`). Гадать по + окружающей прозе гейт не имеет права: угадывающий врёт в обе стороны, а хук, который врёт, учат + обходить. Токен ТРЕБУЕТСЯ только на затронутых строках — так корпус мигрирует по мере касания, а + пре-существующий долг никого не блокирует. + """ + bad: list[str] = [] + tally = {"content": 0, "length": 0, "notoken": 0, "files": 0} targets = [ROOT / "CLAUDE.md"] + [ - p for p in (ROOT / "docs").rglob("*.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]] = {} + for doc in sorted(targets): - text = doc_text(str(doc.relative_to(ROOT))) + doc_rel = str(doc.relative_to(ROOT)) + text = doc_text(doc_rel) if text is None: continue + tally["files"] += 1 for n, line in enumerate(text.splitlines(), 1): for m in ANCHOR.finditer(line): - rel, lineno = m.group(1).lstrip("/"), int(m.group(2)) + 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.relative_to(ROOT)}:{n}" + where = f"{doc_rel}:{n}" if hit is None: - bad.append(f"{where}: якорь `{m.group(0)}` — файла нет ни под одним из префиксов {LINT_PREFIXES}") + # ⚠ Ссылка ВНЕ репозитория (stdlib Go `net/http/server.go`, чужой проект) — не наш + # якорь, судить нечем. Отличаем по первому сегменту: он обязан быть верхнеуровневым + # каталогом репо, иначе это внешняя ссылка. Без этого правила гейт дал три ложные + # тревоги на первом же прогоне, а ложная тревога и есть то, из-за чего гейты + # начинают обходить. + if rel.split("/", 1)[0] not in REPO_TOPS: + continue + bad.append(f"{where}: якорь `{shown}` — файла нет ни под одним из префиксов {LINT_PREFIXES}") continue if hit not in line_counts: - line_counts[hit] = len(hit.read_text(encoding="utf-8", errors="replace").splitlines()) + 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}: якорь `{m.group(0)}` — в {hit.relative_to(ROOT)} всего {line_counts[hit]} строк") - return bad + bad.append(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}» (цель уехала)" + ) + return bad, tally def region(text: str, name: str) -> str: @@ -351,10 +439,26 @@ def region(text: str, name: str) -> str: def main() -> int: if "--lint" in sys.argv: - problems = lint_anchors() - for p in problems: + problems, tally = lint_anchors() + # ⚠ Кап вывода: реструктуризация дока (нарезка слайсов, массовая переразметка) помечает + # затронутыми ВСЕ его строки и вывалила бы весь пре-существующий долг разом. Warn-only + # превращает такой залп в шум, а шум учат скроллить. + CAP = 25 + for p in problems[:CAP]: print(" ✗ " + p) - print(f"\n--lint: {len(problems)} мёртвых/переросших file:line-якорей в живых доках") + if len(problems) > CAP: + print(f" … ещё {len(problems) - CAP} не показано (кап вывода {CAP})") + print(f"\n--lint: {len(problems)} проблемных якорей в {tally['files']} живых доках") + # ⚠ Норма «гейт печатает, чего он НЕ проверяет» (D39.153 п.9г): прежняя редакция молча + # проверяла только существование файла и переполнение номера — и дала ЗЕЛЁНЫЙ на четыре + # якоря, сломанных сдвигом содержимого. Ложная уверенность от гейта дороже его дыры. + print( + f" сверено ПО СОДЕРЖИМОМУ: {tally['content']} · " + f"только существование и длина файла: {tally['length']} · " + f"в дельте, но без токена ожидания — НЕсверяемы: {tally['notoken']}" + ) + print(" НЕ проверяется: содержимое якорей вне дельты коммита; смысловая актуальность текста;") + print(" якоря в archive/** и в доках из LINT_SKIP_DOCS.") return 1 if problems else 0 dlog, prog, reg = read(DLOG), read(PROGRESS), read(REGISTER) diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index d6059da6..f2cc4548 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -368,7 +368,7 @@ 6. **Труба доставки решений банка в движок** — единый бэклог, строка **199(а)**: перед resume писать решения в `mined_delta`/`mined_rejects`. Сегодня `action`/`dst` — write-only колонки (единственный SELECT `readmodel.go:336` проверяет лишь наличие строки), а воркер описан в комментарии вашей же миграции `00002_readmodel.sql:172-174` и не построен. 7. **Мусор:** пустой `platform/ru` (0 байт, обрубок редиректа) — в лендинг не взят, снесён оркестратором. `books.go:234-235` — задвоенная первая строка доккомментария, класс PD-310/326. 8. **Ревью-пак четырёх осей, которых не смотрел НИКТО** (ваш же obstacle): деньги и леджер целиком · вход/сессии/CSRF · очередь и джобы · метрики. Это не дофикс и не довесок — первый взгляд, отдельной работой. -9. **Один носитель на факт для замера 00016→00022 — но НЕ удалением числа.** Выигрыш назван ЧЕТЫРЬМЯ носителями: шапка этого журнала «83%» · `internal/pgstore/perf_test.go:13` «96%» · миграция 00022 «16.8 против 3.4, джойн сам 9 мс» · регистр PD-306 (повторяет 83% и 16.8/3.4). ⚠ Я сперва записал, что «96%» ни из чего не выводится — **это была моя ошибка, снята проверкой записей**: оно выводится точно из ВАШЕГО же замера в `archive/P7_ACT5_FIX_PLAN_2026-08-20.md:444-446` («636 мс против 24 мс», холодный корпус акта 4) = 96.2%, тогда как 83% и мой независимый ≈80% — с вакуумированного корпуса. То есть носители меряли РАЗНОЕ и все честны. Свести указанием УСЛОВИЙ замера при каждом числе, оставив нормативным один (миграция 00022 — она их и несёт); удалять «96%» как фантом НЕЛЬЗЯ. +9. **Один носитель на факт для замера 00016→00022 — но НЕ удалением числа.** Выигрыш назван ЧЕТЫРЬМЯ носителями: шапка этого журнала «83%» · `internal/pgstore/perf_test.go:13`=`96% of the page` «96%» · миграция 00022 «16.8 против 3.4, джойн сам 9 мс» · регистр PD-306 (повторяет 83% и 16.8/3.4). ⚠ Я сперва записал, что «96%» ни из чего не выводится — **это была моя ошибка, снята проверкой записей**: оно выводится точно из ВАШЕГО же замера в `archive/P7_ACT5_FIX_PLAN_2026-08-20.md:444-446`=`636 мс против` («636 мс против 24 мс», холодный корпус акта 4) = 96.2%, тогда как 83% и мой независимый ≈80% — с вакуумированного корпуса. То есть носители меряли РАЗНОЕ и все честны. Свести указанием УСЛОВИЙ замера при каждом числе, оставив нормативным один (миграция 00022 — она их и несёт); удалять «96%» как фантом НЕЛЬЗЯ. 10. **У материализатора нет ПОЛА на пустой манифест — латентная потеря всего текста книги.** `internal/readmodel/readmodel.go:215-232` строит `in.Chapters` только из `manifest.Chapters` и ни разу не сверяется со счётчиками того же документа (`ChaptersTotal`/`UnitsTotal` лежат рядом и печатаются в лог строкой ниже). Пустой список едет в `SaveStructure`, где `pgstore/readmodel.go:193-196` выполняет `delete from chapters where book_id = $1 and not (id = any($2))` — на пустом массиве предикат истинен для ВСЕХ глав, и каскад `chapters → units` сносит текст; при пустом `Key` вдобавок срабатывает `changed` и уходят все `unit_resolutions` (родня строки 198). Сегодняшним движком недостижимо (`buildManifest` всегда наполняет главы, файл пишется атомарно) ⇒ фикс-лист, не блокер. **Асимметрия и есть находка:** на ИНТЕЙКЕ ровно этот случай отловлен явно и прибит мутацией (`internal/books/parse.go:134-142`, `books_test.go:1296`) — «нет глав, но работа считается» там не признаётся правдой о книге. У материализатора такого пола нет, и теста на нулевой манифест в `readmodel_test.go` тоже нет. Лечится одной сверкой `len(manifest.Chapters)` против `manifest.ChaptersTotal`. 11. **`PD-327` в регистре стоит `open`, а канон 0.4.0 РАТИФИЦИРОВАН** (D39.152) — то есть условие её закрытия наступило. Закрыть строку регистра явно; сегодня расхождение видно скриптом (`counts.py --check` печатает PD-327 среди открытых), и это ровно тот класс, ради которого регистр объявлен источником истины по статусу.