diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 57c028b0..83647365 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,6 +1,16 @@ # Журнал решений оркестратора — контракт D1–D39.112 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.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):** карта ниже детально покрывает D1–D39.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 в силе. diff --git a/docs/scripts/counts.py b/docs/scripts/counts.py index 22c68257..2a3010d2 100644 --- a/docs/scripts/counts.py +++ b/docs/scripts/counts.py @@ -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 diff --git a/docs/scripts/githooks/pre-commit b/docs/scripts/githooks/pre-commit index dec810ea..8c47fce2 100755 --- a/docs/scripts/githooks/pre-commit +++ b/docs/scripts/githooks/pre-commit @@ -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 'D1–D39\.[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 'D1–D39\.[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