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

This commit is contained in:
Claude (backend session) 2026-08-21 03:27:23 +03:00
parent 62cab56d24
commit 5bfa61c272
3 changed files with 121 additions and 17 deletions

View file

@ -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, (63624)/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, (63624)/636 = 96.2%. То есть носители меряли разное (холодный корпус против вакуумированного) и оба честны; сводить их надо указанием УСЛОВИЙ замера, а не удалением одного как фантома — иначе следующая сессия снесёт верное число.
**3. Собственные посадки ВНЕ списка автора — 8, поймано 7.** Долг не поставлен третьей концовкой · `edit_wave` не монотонен · снят `Vary` · HEAD лишён валидатора · `reopen` снимает стоп банка кому угодно · погашение долга без сверки метки · вынос метки долга из закрывающей транзакции во второй оператор (техника `xmin` из их §36 работает — проверено исполнением, а не принято на слово). **НЕ поймано:** снятие `structure_version` И `revision` из `emitFrame` проходит ВСЮ батарею — два поля, которые канон требует на КАЖДОМ кадре `EventBase`. По норме самой зоны (PD-1) свойство без пинящего теста считается незакрытым.

View file

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

View file

@ -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 среди открытых), и это ровно тот класс, ради которого регистр объявлен источником истины по статусу.