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:
parent
62cab56d24
commit
5bfa61c272
3 changed files with 121 additions and 17 deletions
|
|
@ -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) свойство без пинящего теста считается незакрытым.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 среди открытых), и это ровно тот класс, ради которого регистр объявлен источником истины по статусу.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue