У протухшей документации нет симптомов. Файл открывается, разметка валидна, дата в шапке выглядит правдоподобно, ссылки ведут куда вели. Единственное, чего в нём нет, — правды о текущем коде. Кто-то месяц назад вынес три функции в отдельный модуль, переименовал точку входа, поменял порядок аргументов. AGENTS.md этого не заметил, потому что заметить было нечем.
Для человека это раздражение: прочитал, споткнулся, пошёл читать код. Для агента это другое. Модель не чувствует «что-то здесь не сходится» — она принимает файл контекста как факт и с одинаковой уверенностью выполняет и свежую инструкцию, и ту, что описывает архитектуру полугодовой давности. Дальше агент пишет код по устаревшему описанию, коммитит и уходит. В ClewWiki — это self-hosted база знаний, где страницы пишут вместе люди и кодинг-агенты, AGPL-3.0, пока pre-alpha без релизов — из этой проблемы вырос механизм code anchors.
Почему «файл и строки» — не якорь#
Очевидное решение выглядит так: записать рядом со страницей src/anchors/resolve.ts:127-184 и проверять, изменились ли эти строки. Оно не работает, и ломается быстрее, чем кажется.
Строчный диапазон не переживает правку выше себя. Кто-то добавил импорт в начало файла — весь код уехал на строку вниз, и диапазон теперь указывает на соседнее объявление. Прогон форматтера переворачивает почти каждый диапазон в репозитории разом. Переименование функции, которое вообще ничего не меняет в поведении, тоже считается изменением. Итог — поток флагов, среди которых настоящих единицы, и через две недели команда перестаёт их читать.
Эту цифру мы измерили, а не оценили на глаз. На реальной истории рефакторингов — 26 коммитов, 479 анкеров — наивные line-range якоря дали около 50% ложных срабатываний. Символьные анкеры на той же истории дали ноль.
Отсюда первое решение: идентичность анкера — это пара {kind, qualified_name}, а не местоположение. Функция languageForPath остаётся собой, переехав в другой файл. Поле file_hint в анкере есть, но оно нужно только чтобы начать поиск с правильного места, а не чтобы определить, что искать.
Line ranges всё же остались — как честный fallback для блоков, у которых нет резолвимого объявления: конфиг, таблица констант, кусок прозы в README. Для них нормализуются отступы и пустые строки, и в каждом ответе check едет fallback_share — доля таких анкеров в space. Растущая доля означает, что пространство постепенно съезжает на путь, который известно как ломается; это ранний индикатор шума, а не украшение ответа.
Хеш токенов, а не хеш текста#
Второе решение — что именно хешировать. Хеш текста объявления бесполезен по той же причине, по которой бесполезен диапазон строк: он реагирует на всё. Поэтому анкер хранит хеш последовательности токенов, которую выдаёт парсер, с выброшенными комментариями.
Практическая разница: переотступ функции, перенос аргументов на несколько строк, переписанный doc-комментарий, смена одинарных кавычек на двойные — ничего из этого не меняет хеш. Изменение того, что код делает, меняет его по построению. Именно такого поведения ждёшь от сигнала «документация про это устарела».
Парсеры — tree-sitter, скомпилированный в WebAssembly. Это не поза: нативные биндинги притащили бы в runtime-образ node-gyp и требовали бы пересборки на каждое обновление Node — ради парсера, который только читает. WASM-сборка кладётся в образ как данные и переживает апгрейд рантайма. Таблицы объявлений сегодня покрывают Swift, TypeScript и TSX (.swift, .ts, .mts, .cts, .tsx); Kotlin следующий.
Лестница из пяти стадий и четыре состояния#
Проверка анкера — это упорядоченная лестница, и её порядок ровно и отделяет рефакторинг от проблемы в документации:
| Стадия | Что ищем | Итог |
|---|---|---|
| 1 | тот же файл, та же идентичность | fresh или stale |
| 2 | другой файл, та же идентичность | moved |
| 3 | то же тело под другим именем в том же контейнере | renamed |
| 4 | то же тело где угодно | moved and renamed |
| 5 | ничего | lost |
Стадии 3 и 4 матчатся по хешу тела, а не полного объявления. Причина механическая: полный хеш покрывает имя, и переименование меняет его по определению — значит, полным хешем переименование поймать невозможно в принципе.
У хеша тела есть своя ловушка: короткие тела совпадают случайно. { return nil } встречается в файле двадцать раз, и любое из них «докажет», что функция переименована именно в него. Поэтому тело короче MIN_BODY_TOKENS = 8 токенов не матчится вообще. Уверенно неправильный ответ хуже честного lost.
Состояний у анкера четыре. fresh — объявление на месте и не изменилось. stale — то же объявление, изменённое тело. moved-renamed — нашли, и ответ говорит, где теперь лежит и как теперь зовётся. lost — не резолвится ничего; это не «сломалось», а запрос решения от человека: удалить анкер, перепривязать или признать, что страница описывает несуществующий код.
Три анкера, один коммит#
Механику я проверил вручную на локальном инстансе. Три symbol-анкера на реальные функции пакета: resolveAnchor, languageForPath, lineRangeAnchor. Первая проверка — fresh, fresh, fresh. Дальше коммит, добавляющий одну строку в тело languageForPath, и повторная проверка:
{
"complete": true,
"budget": { "files_read": 3, "bytes_read": 10524, "elapsed_ms": 35, "limit": null },
"anchors": [
{ "qualified_name": "resolveAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } },
{ "qualified_name": "languageForPath", "state": "stale",
"detail": { "reason": "body_changed" } },
{ "qualified_name": "lineRangeAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } }
],
"fallback_share": { "total": 3, "fallback": 0, "share": 0 }
}Ровно один анкер сменил состояние. Два соседних объявления в том же файле, сдвинутые этой правкой по строкам, остались fresh — потому что строки к идентичности отношения не имеют. В интерфейсе это карточка: «function languageForPath — Stale — The declaration is still there, but its body changed» и кнопка «Confirm reviewed».
Кнопка здесь принципиальна. Флаг снимает только явный confirm (POST /api/v1/anchors/{id}/confirm) — осознанный аудируемый акт человека или агента. Бейдж, исчезнувший сам по себе, обесценил бы молчание всех остальных бейджей: нельзя доверять зелёному статусу, если зелёный иногда означает «никто не смотрел, просто протухло по-тихому».
Анкер резолвится сразу при создании: опечатка в имени символа отклоняется на месте, а не всплывает как загадочный lost через неделю.
Бюджеты и то, чего система не делает#
У одной проверки есть жёсткие пределы: 2000 файлов, 32 МБ исходников, 30 секунд; файлы больше 1 МБ пропускаются; repo-wide стадии читают до 4000 файлов. При исчерпании бюджета ответ говорит об этом прямо — "complete": false, budget.limit, список unchecked_anchor_ids, — и непроверенные анкеры сохраняют предыдущее состояние. Помечать их lost означало бы врать про код, которого мы не читали.
Репозиторий при этом читается, но никогда не запускается. На space кладётся bare mirror под REPOS_DIR, блобы достаются через git show, рабочее дерево не создаётся — значит, ни сборка, ни install-скрипт, ни хук выполниться физически не могут. Креденшл приватного репозитория задаётся именем переменной окружения, не значением:
CLEWWIKI_GIT_TOKEN_INTERNAL=ghp_... # значение живёт только в окруженииВ настройках репозитория сохраняется строка CLEWWIKI_GIT_TOKEN_INTERNAL — имя, а не секрет; допустимы только CLEWWIKI_GIT_TOKEN и CLEWWIKI_GIT_TOKEN_<NAME>.
Токен уходит только на https-origin своего репозитория и не попадает ни в базу, ни в бэкап, ни в API-ответ.
Тот же механизм staleness работает и без кода: у страницы может быть парный документ (technical ↔ human), и тогда целью анкера становится content hash пары — техническая версия узнаёт, что человеческая ушла вперёд.
Проект в pre-alpha: тегов нет, образ в GHCR и npm-пакет не опубликованы, единственный способ запустить — собрать из исходников. Но конкретно эта часть уже отвечает на вопрос, ради которого писалась: страница больше не может молча утверждать неправду про функцию, которую вчера переписали.



