Harden the docs guard against its own failure modes and make errata findable from the map instead of only from the tail of the log

This commit is contained in:
heaven 2026-08-08 00:09:33 +03:00
parent ca6f0a73c4
commit 0e189d2a4a
3 changed files with 158 additions and 102 deletions

View file

@ -1,6 +1,16 @@
# Журнал решений оркестратора — контракт D1D39.112 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> ⚠ **Навигация (актуализация 07.08, эра D39.1xx):** append-only-дисциплина (D23.3) означает, что
> НЕВЕРНЫЙ ФАКТ внутри старой ноты не переписывается, а получает эрратау — и тогда он опасен ровно
> тем, что грепающий его найдёт ПЕРВЫМ, а эрратау ниже может не дочитать. Поэтому поправки к фактам
> живут ЗДЕСЬ, в карте, а не только в хвосте: **D39.109 п.3 читать через D39.112 п.2(а)** («30 суток
> — не буква NIST: SHALL требует установить overall-таймаут, 30 суток это SHOULD; сбрасывает ли его
> успешная ре-аутентификация, NIST не говорит — наш жёсткий предел это НАШ выбор, строже нормы») ·
> **D39.109 п.4 читать через D39.106 п.3** (PD-59 superseded, пайп-путь `supervisor.go` = дев-режим)
> · **D39.109 п.1 и «живой уязвимости не нашла» читать через D39.112 п.2(в)** (отказ в обслуживании
> входа ЕСТЬ — PD-80) · **D39.110 п.1 «дословно по смыслу» читать как ПЕРЕСКАЗ** решения, не цитату.
> ⚠ **Навигация (актуализация 04.08):** два supersede-указателя эры D39.9x: **D39.88/D39.84 п.8 (право самокоммита фронта/платформы) → отозвано, коммитит ТОЛЬКО оркестратор** (D39.98 п.3; тела переписаны на месте с санкцией владельца, pre-rewrite — git `2b166b7`) · **норма 30.07 «короткая приёмка» → амендирована владельцем 03.08: приёмка всегда адверсариальная** (носитель — промт оркестратора §Анти-паттерны + D39.101 п.1) — упоминания «короткой приёмки» в телах читать через эту пометку · **двухсекционная редакторская инъекция и смягчающая роль маркера ⟨проверить⟩ → доктрина инжекта D39.104**: банк на проводе = ЗАКОН для всех ролей независимо от статуса строки, право «перевести иначе» упразднено, блок редактора — ЕДИНЫЙ, маркер с провода снимается (статусы строк и подписная таблица не меняются; внесение в движок — строка 134 после пробы 18) — упоминания двухсекционки/смягчения в телах читать через это.
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`.
> - **Полностью superseded:** **D1 (моно-редактор) → D17 → D30.1 (редактор БИЛИНГВ)** · D9 (ja-приёмка) → D18 (蛊真人 zh→ru; ja — второй прогон) · D17 → D30.1 · скобка D19.4 «ключ без data-sharing» и D20.3 «чистый ключ = блокер пилота» → **D27** (единый ключ С data-sharing, отключение перед продом) · exp04-дефолт «editor grok-4.3» (D3) → D30.1 (grok reasoning-off из редакторских ролей СНЯТ — no-op; кандидат glm-5-билингв; gemini — премиум-эскалация только за санитайзером D30.3). · **Транспорт шва D39.85 §1 (stdout-пайп, платформа-родитель) → D39.106** (журнал в каталоге книги + systemd-юнит-на-прогон); остальное D39.85 в силе.

View file

@ -1,79 +1,95 @@
#!/usr/bin/env python3
"""Печатает производные числа доков, которые до сих пор переписывались руками.
Заведён после верификации записей 15 (D39.112 п.4): за одну сессию четыре из десяти
рукописных копий производных чисел разошлись с источником. Норма D39.101 требует считать
очередь «только скриптом» скрипта при этом не существовало, и норма исполнялась глазами.
Заведён после верификации записей 15 (D39.112 п.4): за одну сессию четыре из десяти рукописных
копий производных чисел разошлись с источником. Норма D39.101 требует считать очередь «только
скриптом» скрипта при этом не существовало, и норма исполнялась глазами.
Числа, которые печатает этот файл, в доках писать литералами больше не надо: строка носителя
несёт команду. Кто хочет литерал обязан сверить его этой командой в том же касании.
Числа, которые печатает этот файл, в доках писать литералами больше не надо: строка носителя несёт
команду. Кто всё же пишет литерал обязан сверить его этой командой в том же касании.
Использование: python3 docs/scripts/counts.py [--check]
без флага печатает числа для вставки в доки
--check сверяет литералы, найденные в доках, с пере-счётом; ненулевой код = расхождение
python3 docs/scripts/counts.py печатает числа
python3 docs/scripts/counts.py --check сверяет литералы доков с пере-счётом; код 1 = расхождение
python3 docs/scripts/counts.py --check --from-index то же, но по СОДЕРЖИМОМУ КОММИТА
(индекс для застейдженного файла, HEAD для остальных)
Про `--from-index`: без него скрипт читает рабочее дерево, и при ЧАСТИЧНОМ стейдже (техника
«застейджить только свою правку в общем файле») он честно ругается на то, что в коммит не поедет.
Хук зовёт именно `--from-index`, чтобы не давать ложных тревог хук, который врёт, учат игнорировать.
Дисциплина самого скрипта: если ОЖИДАЕМЫЙ литерал в доке не найден, это РАСХОЖДЕНИЕ, а не тишина.
Проверка, которая молча перестала проверять из-за переформулировки, хуже отсутствующей.
"""
from __future__ import annotations
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PROGRESS = ROOT / "docs" / "PROGRESS.md"
DLOG = ROOT / "docs" / "architecture" / "05-decisions-log.md"
REGISTER = ROOT / "platform" / "docs" / "DEFECT_REGISTER.md"
PROGRESS = "docs/PROGRESS.md"
DLOG = "docs/architecture/05-decisions-log.md"
REGISTER = "platform/docs/DEFECT_REGISTER.md"
# Строка таблицы бэклога: «| 145 | ...» или «| 13а | ...». Заголовки-разделители («| **— ... —** |»)
# и шапка таблицы под этот вид не подходят и потому не считаются.
BACKLOG_ROW = re.compile(r"^\| (\d+[а-яё]?) \|")
REGISTER_ROW = re.compile(r"^\| (PD-\d+) \|")
NOTE = re.compile(r"^## (D39\.\d+)")
FROM_INDEX = "--from-index" in sys.argv
def read(rel: str) -> str:
"""Содержимое файла: из рабочего дерева либо из того, что поедет в коммит."""
if not FROM_INDEX:
return (ROOT / rel).read_text(encoding="utf-8")
staged = subprocess.run(
["git", "diff", "--cached", "--name-only"], cwd=ROOT, capture_output=True, text=True
).stdout.split()
ref = f":{rel}" if rel in staged else f"HEAD:{rel}"
got = subprocess.run(["git", "show", ref], cwd=ROOT, capture_output=True, text=True)
if got.returncode != 0:
# Новый файл вне индекса или отсутствующий в HEAD — читаем дерево, но говорим об этом.
print(f" (⚠ {rel}: {ref} недоступен, читаю рабочее дерево)", file=sys.stderr)
return (ROOT / rel).read_text(encoding="utf-8")
return got.stdout
def cells(line: str) -> list[str]:
return [c.strip() for c in line.split("|")]
def backlog() -> dict:
rows = [l for l in PROGRESS.read_text(encoding="utf-8").splitlines() if BACKLOG_ROW.match(l)]
def backlog(text: str) -> dict:
rows = [l for l in text.splitlines() if BACKLOG_ROW.match(l)]
ids = [BACKLOG_ROW.match(l).group(1) for l in rows]
dupes = [i for i, n in Counter(ids).items() if n > 1]
strict = sum(1 for l in rows if cells(l)[3] == "бэкенд")
wide = sum(1 for l in rows if "бэкенд" in cells(l)[3])
soon = [BACKLOG_ROW.match(l).group(1) for l in rows if "скоро" in cells(l)[4]]
blockers = sum(1 for l in rows if "блокер-очереди" in cells(l)[4])
return {
"всего": len(rows),
"бэкенд строго": strict,
"бэкенд широко": wide,
"скоро": len(soon),
"скоро поимённо": soon,
"блокеров очереди": blockers,
"дубли ID": dupes,
"бэкенд строго": sum(1 for l in rows if cells(l)[3] == "бэкенд"),
"бэкенд широко": sum(1 for l in rows if "бэкенд" in cells(l)[3]),
"скоро": sum(1 for l in rows if "скоро" in cells(l)[4]),
"блокеров очереди": sum(1 for l in rows if "блокер-очереди" in cells(l)[4]),
"дубли ID": [i for i, n in Counter(ids).items() if n > 1],
}
def register() -> dict:
rows = [l for l in REGISTER.read_text(encoding="utf-8").splitlines() if REGISTER_ROW.match(l)]
weight = Counter()
status = Counter()
open_ids = []
def register(text: str) -> dict:
rows = [l for l in text.splitlines() if REGISTER_ROW.match(l)]
weight, status, open_ids = Counter(), Counter(), []
for l in rows:
c = cells(l)
st, sev = c[6], c[3].replace("*", "")
if st == "open":
status["open"] += 1
open_ids.append(c[1])
# Вес читается по вхождению слова: колонка часто несёт оговорку («major (для промта…)»).
weight["major" if "major" in sev else "minor" if "minor" in sev else "info" if "info" in sev else sev] += 1
elif st.startswith("fixed"):
status["fixed"] += 1
elif st.startswith("accepted-risk"):
status["accepted-risk"] += 1
else:
status["иное: " + st[:32]] += 1
status["иное"] += 1
return {
"всего строк": len(rows),
"по статусу": dict(status),
@ -82,37 +98,59 @@ def register() -> dict:
}
def head() -> dict:
"""Голова = последняя нота D-лога; она же обязана стоять в шапке CURRENT-STATE."""
notes = [NOTE.match(l).group(1) for l in DLOG.read_text(encoding="utf-8").splitlines() if NOTE.match(l)]
last = notes[-1] if notes else None
first = PROGRESS.read_text(encoding="utf-8").splitlines()[:6]
def head(dlog: str, prog: str) -> dict:
notes = [NOTE.match(l).group(1) for l in dlog.splitlines() if NOTE.match(l)]
banner = re.search(r"D1(D39\.\d+)", dlog.splitlines()[0]) if dlog else None
claimed = None
for l in first:
for l in prog.splitlines()[:8]:
m = re.search(r"голова (D39\.\d+)", l)
if m:
claimed = m.group(1)
break
banner = re.search(r"D1(D39\.\d+)", DLOG.read_text(encoding="utf-8").splitlines()[0])
last = notes[-1] if notes else None
b = banner.group(1) if banner else None
return {
"последняя нота D-лога": last,
"шапка D-лога": b,
"голова в CURRENT-STATE": claimed,
"шапка D-лога": banner.group(1) if banner else None,
"сходится": last == claimed == (banner.group(1) if banner else None),
"сходится": bool(last) and last == claimed == b,
}
def main() -> int:
b, r, h = backlog(), register(), head()
check = "--check" in sys.argv
# Литералы, которые доки обязаны держать в согласии с пере-счётом. Каждый ОБЯЗАН найтись:
# ненайденный литерал = проверка перестала проверять, и это докладывается как расхождение.
# region ограничивает поиск, чтобы историческая секция со старым числом не давала ложной тревоги.
LITERALS = [
(PROGRESS, "current-state", r"всего \*\*(\d+)\*\* строк", ("backlog", "всего")),
(PROGRESS, "current-state", r"зона бэкенд \*\*(\d+)\*\* строго", ("backlog", "бэкенд строго")),
(PROGRESS, "current-state", r"/ \*\*(\d+)\*\* широко", ("backlog", "бэкенд широко")),
(PROGRESS, "current-state", r"«скоро» \*\*(\d+)\*\*", ("backlog", "скоро")),
(PROGRESS, "current-state", r"\*\*(\d+) major\*\*", ("register-weight", "major")),
]
def region(text: str, name: str) -> str:
"""current-state = шапка PROGRESS до таблицы бэклога; там и живут сводные числа."""
if name != "current-state":
return text
cut = text.find("## Бэклог")
return text[: cut if cut > 0 else len(text)]
def main() -> int:
dlog, prog, reg = read(DLOG), read(PROGRESS), read(REGISTER)
b, r, h = backlog(prog), register(reg), head(dlog, prog)
check = "--check" in sys.argv
src = "содержимое коммита (индекс/HEAD)" if FROM_INDEX else "рабочее дерево"
print(f"ИСТОЧНИК: {src}\n")
print("ГОЛОВА")
for k, v in h.items():
print(f" {k}: {v}")
print("\nБЭКЛОГ (docs/PROGRESS.md)")
print("\nБЭКЛОГ")
for k, v in b.items():
print(f" {k}: {v if k != 'скоро поимённо' else '·'.join(v)}")
print("\nРЕГИСТР ПЛАТФОРМЫ (platform/docs/DEFECT_REGISTER.md)")
print(f" {k}: {v}")
print("\nРЕГИСТР ПЛАТФОРМЫ")
for k, v in r.items():
print(f" {k}: {v if k != 'открытые' else ' '.join(v)}")
@ -123,36 +161,29 @@ def main() -> int:
if not h["сходится"]:
bad.append(
f"голова разошлась: последняя нота {h['последняя нота D-лога']}, "
f"CURRENT-STATE {h['голова в CURRENT-STATE']}, шапка D-лога {h['шапка D-лога']}"
f"шапка D-лога {h['шапка D-лога']}, CURRENT-STATE {h['голова в CURRENT-STATE']}"
)
if b["дубли ID"]:
bad.append(f"дубли ID строк бэклога: {b['дубли ID']}")
# Литералы в доках: ищем только те формы, которые доки реально используют.
prog = PROGRESS.read_text(encoding="utf-8")
m = re.search(r"всего \*\*(\d+)\*\* строк", prog)
if m and int(m.group(1)) != b["всего"]:
bad.append(f"счёт очереди в CURRENT-STATE {m.group(1)} против пере-счёта {b['всего']}")
m = re.search(r"зона бэкенд \*\*(\d+)\*\* строго .*?/ \*\*(\d+)\*\* широко", prog)
if m and (int(m.group(1)), int(m.group(2))) != (b["бэкенд строго"], b["бэкенд широко"]):
bad.append(
f"счёт зоны бэкенд {m.group(1)}/{m.group(2)} против пере-счёта "
f"{b['бэкенд строго']}/{b['бэкенд широко']}"
)
for path in (PROGRESS, ROOT / "platform" / "docs" / "platform-PROGRESS.md"):
text = path.read_text(encoding="utf-8")
for mm in re.finditer(r"\*\*(\d+) major\*\*", text):
if int(mm.group(1)) != r["вес открытых"].get("major", 0):
bad.append(
f"{path.name}: «{mm.group(1)} major» против пере-счёта "
f"{r['вес открытых'].get('major', 0)}"
)
texts = {PROGRESS: prog, REGISTER: reg}
for path, reg_name, pattern, (kind, key) in LITERALS:
want = b[key] if kind == "backlog" else r["вес открытых"].get(key, 0)
found = re.search(pattern, region(texts[path], reg_name))
if not found:
bad.append(
f"{path}: литерал по шаблону /{pattern}/ НЕ НАЙДЕН — либо формулировку сменили, "
f"либо число выпало; проверка этого числа перестала работать (ожидалось {want})"
)
elif int(found.group(1)) != want:
bad.append(f"{path}: «{found.group(0)}» против пере-счёта {want}")
if bad:
print("\nРАСХОЖДЕНИЯ:")
for line in bad:
print("" + line)
return 1
print("\nЛитералы в доках сходятся с пере-счётом.")
print(f"\nЛитералы сходятся с пере-счётом ({len(LITERALS)} проверок).")
return 0

View file

@ -1,16 +1,28 @@
#!/bin/sh
# Зонный pre-commit фрагмент docs/. Запускается тем же диспетчером .git/hooks/pre-commit,
# что и фрагмент фронта (он перебирает */scripts/githooks/pre-commit).
# Зонный pre-commit фрагмент docs/. Ставится тем же диспетчером .git/hooks/pre-commit, что и
# фрагмент фронта (диспетчер перебирает */scripts/githooks/pre-commit).
#
# Зачем: за одну сессию №15 четыре из десяти рукописных копий производных чисел разошлись с
# источником, а голова CURRENT-STATE отставала ТРЕТИЙ раз в истории проекта — у трёх разных
# оркестраторов (D39.81, D39.83, D39.112 п.5б). Одна и та же ошибка у независимых сессий на одном
# поле — свойство поля, а не сессий, поэтому проверка механическая, а не нормой прозой.
# ┌─ ЧТО ЭТО ДЕЛАЕТ И ЧЕГО НЕ ДЕЛАЕТ — читать до того, как ругаться на него ───────────────────┐
# │ ДЕЛАЕТ: при коммите, задевающем D-лог или PROGRESS, сверяет три носителя номера головы и │
# │ производные числа доков (счёт очереди, зон, вес открытых строк регистра платформы). │
# │ НЕ ДЕЛАЕТ: не блокирует коммит НИКОГДА — только печатает предупреждение и выходит 0. │
# │ Не трогает содержимое, не правит файлы, не лезет в сеть, не запускает тесты. │
# │ ЕСЛИ МЕШАЕТ: `git commit --no-verify` обходит всю цепочку; выключить только этот фрагмент — │
# │ `chmod -x docs/scripts/githooks/pre-commit`. Ни то, ни другое ничего не ломает. │
# │ ПОЧЕМУ ВООБЩЕ: голова CURRENT-STATE отставала ТРИЖДЫ у трёх РАЗНЫХ оркестраторов │
# │ (D39.81 · D39.83 · D39.112 п.5б). Одна ошибка у независимых сессий на одном поле — │
# │ свойство поля, а не сессий, поэтому проверка механическая, а не нормой прозой. │
# │ ПОЧЕМУ ПРЕДУПРЕЖДАЕТ, А НЕ БЛОКИРУЕТ: фрагмент общий, он сработает у сессии, которой я не │
# │ писал промт. Останавливать чужой коммит — решение владельца, не моё (оркестратор №15). │
# └────────────────────────────────────────────────────────────────────────────────────────────┘
#
# ⚠ ПРЕДУПРЕЖДАЕТ, НЕ БЛОКИРУЕТ (решение оркестратора №15, 07.08): фрагмент лежит в общем
# диспетчере и сработает на коммите ЛЮБОЙ сессии, включая те, которым я не писал промт. Жёсткий
# гейт, останавливающий чужой коммит, ставится только словом владельца. Обход, если понадобится:
# git commit --no-verify.
# Ложные тревоги, которых здесь СОЗНАТЕЛЬНО избегают, — это важнее, чем полнота: хук, который
# врёт, учат игнорировать, и тогда он хуже отсутствующего.
# · числа считаются по СОДЕРЖИМОМУ КОММИТА (`--from-index`), а не по рабочему дереву, иначе
# частичный стейдж (своя правка в общем файле) давал бы ругань на то, что в коммит не поедет;
# · голова сверяется ТОЛЬКО когда в коммите есть D-лог, то есть когда идёт ратификация — норма
# требует бампать голову в том же коммите, что аппенд ноты, и вот этот случай и ловится;
# коммит, который правит PROGRESS по другому поводу, про голову не спрашивают.
staged=$(git diff --cached --name-only)
[ -z "$staged" ] && exit 0
@ -18,37 +30,40 @@ staged=$(git diff --cached --name-only)
dlog='docs/architecture/05-decisions-log.md'
prog='docs/PROGRESS.md'
# Быстрый выход: ни один из двух носителей не в коммите — нам тут нечего делать.
printf '%s\n' "$staged" | grep -qE "^($dlog|$prog)$" || exit 0
# Содержимое ПОСЛЕ коммита: для застейдженного файла это индекс, для нетронутого — HEAD.
after() {
if printf '%s\n' "$staged" | grep -qx "$1"; then git show ":$1" 2>/dev/null; else git show "HEAD:$1" 2>/dev/null; fi
}
note=$(after "$dlog" | grep -oE '^## D39\.[0-9]+' | tail -1 | sed 's/^## //')
banner=$(after "$dlog" | head -1 | grep -oE 'D1D39\.[0-9]+' | sed 's/^D1//')
claimed=$(after "$prog" | head -6 | grep -oE 'голова D39\.[0-9]+' | head -1 | sed 's/^голова //')
warn=0
if [ -n "$note" ] && [ -n "$claimed" ] && [ "$note" != "$claimed" ]; then
echo "pre-commit ⚠ docs: голова разошлась — последняя нота D-лога $note, CURRENT-STATE говорит $claimed." >&2
echo " Норма промта оркестратора: голова бампается при КАЖДОМ аппенде ноты, в том же коммите." >&2
warn=1
fi
if [ -n "$note" ] && [ -n "$banner" ] && [ "$note" != "$banner" ]; then
echo "pre-commit ⚠ docs: шапка-диапазон D-лога $banner при последней ноте $note." >&2
warn=1
# 1) Голова. Только при ратификации (D-лог в коммите).
if printf '%s\n' "$staged" | grep -qx "$dlog"; then
after() {
if printf '%s\n' "$staged" | grep -qx "$1"; then git show ":$1" 2>/dev/null; else git show "HEAD:$1" 2>/dev/null; fi
}
note=$(after "$dlog" | grep -oE '^## D39\.[0-9]+' | tail -1 | sed 's/^## //')
banner=$(after "$dlog" | head -1 | grep -oE 'D1D39\.[0-9]+' | sed 's/^D1//')
claimed=$(after "$prog" | head -8 | grep -oE 'голова D39\.[0-9]+' | head -1 | sed 's/^голова //')
if [ -n "$note" ] && [ -n "$claimed" ] && [ "$note" != "$claimed" ]; then
echo "pre-commit ⚠ docs: голова разошлась — последняя нота D-лога $note, CURRENT-STATE говорит $claimed." >&2
echo " Норма: голова бампается в ТОМ ЖЕ коммите, что аппенд ноты (промт оркестратора)." >&2
warn=1
fi
if [ -n "$note" ] && [ -n "$banner" ] && [ "$note" != "$banner" ]; then
echo "pre-commit ⚠ docs: шапка-диапазон D-лога $banner при последней ноте $note." >&2
warn=1
fi
fi
# Производные числа доков — сверка пере-счётом (docs/scripts/counts.py). Считает по РАБОЧЕМУ
# дереву: при частичном стейдже это advisory, а не приговор.
# 2) Производные числа — по содержимому коммита. Ненайденный литерал скрипт тоже считает
# расхождением: проверка, молча перестающая проверять, хуже отсутствующей.
if command -v python3 >/dev/null 2>&1 && [ -f docs/scripts/counts.py ]; then
out=$(python3 docs/scripts/counts.py --check 2>&1) || {
echo "pre-commit ⚠ docs: литералы в доках разошлись с пере-счётом:" >&2
if ! out=$(python3 docs/scripts/counts.py --check --from-index 2>&1); then
echo "pre-commit ⚠ docs: производные числа разошлись с пере-счётом:" >&2
printf '%s\n' "$out" | grep '✗' >&2
warn=1
}
fi
fi
[ "$warn" -eq 1 ] && echo "pre-commit: это ПРЕДУПРЕЖДЕНИЕ, коммит не остановлен." >&2
[ "$warn" -eq 1 ] && echo "pre-commit: это ПРЕДУПРЕЖДЕНИЕ — коммит не остановлен (обход: --no-verify)." >&2
exit 0