From 0e44f8a69f01c94b15b1ef48b411f5081b8e686d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:24:26 +0300 Subject: [PATCH] feat(process): a track:ask draft may be written during the spec review (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On track:ask every spec review round is 10-45 minutes of waiting, and rule #1 kept the author idle for all of it. Rule 10 (#738) judges a class A commit by its author date, so code written in the S4-spec-review epoch was always refused: nothing told a draft written against the reviewed text from a violation. The owner allowed changing rule #1 for this (decision 2026-10-01). A draft commit carries `Spec-Draft: sha256:`, the hash the pipeline already writes as "Тело issue" into the review document anchor. Rule 10 accepts a class A commit written in S4 only when its S4 epoch (a repeated S4 does not restart it) was closed by S5-ready, the track at the author date was ask, and the trailer equals the body of the green, High 0 SPEC-REVIEW added inside that epoch, read from the range head or origin/dev. The first failing check is the one finding: trailer format, track, how the epoch ended, the missing document with a `git fetch origin dev` hint, or both hashes and the document name. A trailer on a commit written in an allowed epoch is a warn. Without the document reader rule 10 is exactly #738; main always passes one, and it reads git only when the range holds a draft. The task packet tells S4 on ask that a local draft is allowed while the branch stays closed, prints the trailer line, and in S5/S6 names the green spec review, whether the body changed since, and the --report check before push. SPEC-REVIEW documents are a separate input (specDocs) from the branch and origin/dev, so the previous verdict and the AC witness keep their source. PROCESS.md gets §11.8 and the points that refer to it (§1, §2.4-2.6, §3 item 1, §7.2, §9, §10.2 item 10, §12); AUTHOR.md, REVIEWER.md and AGENTS.md follow, with three new key rules in process-digests. The pre-push hook fixture copies the modules process-gate now imports. Issue: #729 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- AGENTS.md | 8 +- PROCESS.md | 94 ++++++++- docs/process/AUTHOR.md | 42 +++- docs/process/REVIEWER.md | 3 + scripts/process-gate.mjs | 194 +++++++++++++++++- scripts/task-packet.mjs | 132 +++++++++++-- test/pre-push-gate.test.mjs | 5 + test/process-digests.test.mjs | 5 + test/process-gate.test.mjs | 356 +++++++++++++++++++++++++++++++++- test/task-packet.test.mjs | 77 +++++++- 10 files changed, 882 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3999e6e6..2e259d44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,11 @@ gh issue view --repo Matysh/houseplan-card --json number,state,labels The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits. -Start with the spec?" is the correct answer, not a smaller patch. +Start with the spec?" is the correct answer, not a smaller patch. The one +exception is a local, unpushed draft on `track:ask` while the spec is in +`S4-spec-review`, each commit carrying the `Spec-Draft` trailer from the task +packet; it reaches the branch only after a green spec review (`PROCESS.md` +§11.8). GitHub Issues are the canonical task records and the **labels** are the status (`PROCESS.md` §9); when repository documentation disagrees with an issue, the @@ -178,7 +182,7 @@ set. | Now reads | What happened | What you do | |---|---|---| -| `S5-ready` | the spec is accepted | write the code | +| `S5-ready` | the spec is accepted | write the code (a draft from `S4`: rebase onto `dev`, check, push — §11.8) | | `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` | | `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which | | `S8-merged` | accepted and already in `dev` | nothing | diff --git a/PROCESS.md b/PROCESS.md index 4f3cf4c2..7ff54d9a 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -43,7 +43,8 @@ **Изменение продуктового кода без issue запрещено.** Код меняется только тогда, когда issue существует и находится в статусе «Готово к разработке» или дальше. -Исключения — только §11, и каждое оставляет след. +Исключения — только §11 (в том числе черновик `track:ask` во время ревью ТЗ, +§11.8), и каждое оставляет след. Правило работает лишь при точной границе «продуктового кода», иначе спор переносится на границу: @@ -172,13 +173,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → правится. «Оставили в тексте ревью» не считается закрытием ни для одной (решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте). Low либо правится, либо снимается решением ревьюера с записью. +- **Черновик автора** (§11.8) ревьюер ТЗ не читает и доводом не считает: + судится тело issue. - **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более 4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10). ### 2.5 Готово к разработке (DoR) -Не работа, а **очередь**: единственный статус, из которого можно трогать код. -Все пункты обязательны: +Не работа, а **очередь**: единственный статус, из которого можно трогать код +в ветку задачи; локальный черновик `track:ask` — §11.8. Все пункты обязательны: - ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте. На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом @@ -203,7 +206,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → - **Занятие (claim):** назначить себя, поставить метку, комментарий «Взял: <роль> · сессия · ветка `issue/-`». - **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более - **3** одновременно на цикл релиза, не более **2** в «Код-ревью». + **3** одновременно на цикл релиза, не более **2** в «Код-ревью». Черновик + `track:ask` во время ревью ТЗ (§11.8) занимает тот же слот: у исполнителя + одновременно одна задача в `S6-in-progress` или с черновиком. - **Трассируемость:** ветка `issue/-`; каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #` и `User-Visible: yes|no`; коммит только из документации — без трейлеров (§3 п.10, #701). @@ -459,7 +464,8 @@ patch-id кандидата слияния: вердикт к работе за Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной. 1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к - разработке» или дальше. + разработке» или дальше. Исключение — локальный черновик на `track:ask` во + время ревью ТЗ (§11.8): в ветку он попадает только после зелёного ревью ТЗ. 2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека (§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` — строка «что меняется и чем проверить»), доказательства для каждого AC и @@ -804,6 +810,8 @@ issue #NN - **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип · поверхности: … · дубликаты: … · трек: ship/show/ask (причина)` - **Занятие:** `Взял: <роль> · сессия · ветка issue/NN-slug` +- **Черновик** (§11.8): `Черновик: <роль> · сессия · локальная ветка + issue/NN-slug · Spec-Draft sha256:<первые 12>` - **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> · НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…` - **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r · @@ -1018,7 +1026,7 @@ Performance зелёные на точном SHA, плюс зелёный E2E н | `S2-analysis` | Аналитика и оценка | | `S3-spec` | ТЗ в работе | | `S4-spec-review` | ТЗ на ревью | -| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код | +| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код в ветку задачи; локальный черновик `track:ask` — §11.8 | | `S6-in-progress` | В разработке, занято исполнителем | | `S7-code-review` | Код-ревью | | `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске | @@ -1200,7 +1208,12 @@ Validate — провенанс, процессный гейт, `no-new-any`, `n готовности: в эпоху `S4` код класса A не пишется, как и в `S3`. Коммиты, написанные до возврата, остаются законными. Без timeline или без единого события из множества проверки 8 — предупреждение, а не отказ: проверка - вторична, fail closed по статусу держит правило 8. + вторична, fail closed по статусу держит правило 8. Исключение одно — + черновик `track:ask` (§11.8, #729): коммит из эпохи `S4-spec-review`, + которую закрыл `S5-ready`, принимается, если трек на момент написания — + `ask`, а трейлер `Spec-Draft` равен «Тело issue» зелёного `SPEC-REVIEW` + этой эпохи из вершины диапазона или `origin/dev`; трейлер на коммите вне + `S4` — предупреждение. Не реализовано и остаётся долгом: @@ -1768,11 +1781,76 @@ Low в issue не пишутся. Цвет ночи — цвет Validate. чинится отдельной задачей, затем ревью переснимается (`-f force=true`). Medium и Low решает владелец, как в §11.5. +### 11.8 Черновик кода во время ревью ТЗ + +Решение владельца 2026-10-01, issue #729 (выделено из #707). + +**Зачем.** На `track:ask` каждый раунд ревью ТЗ — 10–45 минут ожидания +(§10.4), и по правилу №1 автор всё это время код не пишет. Черновик позволяет +вести реализацию параллельно ревью. **Цена:** окно, в котором код пишется по +ещё не принятому тексту; после жёлтого или красного вердикта такой код +переделывается, и токены на него потрачены зря. Экономия ожидания против этого +окна — выбор владельца; поэтому **черновик — возможность, а не обязанность**. + +**Условия.** + +- Только `track:ask`, только после того, как ТЗ в теле issue отправлено и + стоит `S4-spec-review`. Под `blocked` и `review-4` черновик не ведётся. +- Один checkout и одна каноническая ветка: черновик живёт в рабочей копии + автора задачи, на локальной ветке `issue/-` — той, что будет + запушена после `S5`. Вторых веток `issue/-*` и параллельных черновиков + нет. +- До `S5` ничего не пушится — ни коммиты класса A, ни сама ветка: на этапе + spec конвейер выбирает ветку `issue/-*` материалом и кладёт в неё + SPEC-REVIEW. Если ветка уже есть на origin (после `reclassify` #726 или + запушена в `S3`), черновик после `S5` ребейзится на `origin/issue/-*`, + где лежит SPEC-REVIEW, а не только на `dev`; force-push поверх неё запрещён. +- Статус остаётся `S4-spec-review`; занятие `S6-in-progress` — только после + зелёного ревью ТЗ. +- Начиная черновик, автор пишет комментарий «Черновик:» по шаблону §7.2. С + этого момента черновик занимает слот WIP автора (§2.6). +- **Трейлер.** Каждый черновой коммит несёт ровно один трейлер + `Spec-Draft: sha256:<64 строчных hex>` в финальном блоке рядом с `Issue:` и + `User-Visible:`. Значение — `issueBodyDigest(<тело issue>)` из + `scripts/review-doc-guard.mjs`: та же функция, которой конвейер пишет «Тело + issue:» в якорь документа ревью. Строку печатает пакет задачи в `S4` + (`node scripts/task-packet.mjs --issue NN`, раздел «Черновик»). Хеш + считается по телу на момент начала черновика; тело правилось после этого — + хеш другой, это и есть «ТЗ правилось». + +**Выход.** Зелёный вердикт ставит `S5-ready`. Автор берёт `S6-in-progress`, +делает `git fetch`, ребейзит черновик на `origin/dev` (или на +`origin/issue/-*`, если ветка была), проверяет +`node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report` и +пушит. **В ветку черновик попадает только после зелёного ревью ТЗ.** Push, +`S7` и слияние, как и прежде, идут только после зелёного ревью актуальной +версии ТЗ: это держат правило 8 (§10.2) и шаг «ТЗ менялось после зелёного +ревью ТЗ» (#517). + +**Жёлтый или красный вердикт** возвращает задачу в `S3-spec`, и черновик +останавливается. Его коммиты в ветку не попадают: как есть их отклонит гейт, а +перенос с переписанной датой автора (`--reset-author`, `--date`, новый коммит +из старого диффа) запрещён §12. В следующей эпохе `S4` можно начать новый +черновик — по новому тексту и с новым хешем. + +**Проверка** — правило 10 `process-gate.mjs` (§10.2): коммит класса A, +написанный в эпохе `S4-spec-review`, принимается, только если эту эпоху закрыл +`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue» +зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или +`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ. +Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`. + +Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue +(§2.4). + --- ## 12. Запрещено -- код без issue или из статуса раньше «Готово к разработке»; +- код без issue или из статуса раньше «Готово к разработке» (кроме черновика + `track:ask` по §11.8); +- push черновика до `S5`; перенос черновика по устаревшему ТЗ коммитами с + переписанной датой автора; - ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»); - ревью своей работы; перевод своей работы через ревью-гейт; - пятый цикл ревью вместо разбора по §4; diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index 8358e249..301cf3cd 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -14,7 +14,9 @@ - **Изменение продуктового кода без issue запрещено.** Код меняется только из `S5-ready` или дальше ([§1](../../PROCESS.md#1-основное-правило), - [§3 п.1–2](../../PROCESS.md#3-правила)). + [§3 п.1–2](../../PROCESS.md#3-правила)). Исключение — локальный черновик на + `track:ask` во время ревью ТЗ + ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)). - Классы: A — продукт (`src/**`, `custom_components/houseplan/**/*.py`, манифесты, i18n); B — гейты и инструменты (`test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/**`, конфиги сборки, `package*.json`); @@ -112,6 +114,34 @@ бюджет, сразу ставит `review-4`; исчерпание — решение владельца: разделить, отклонить, арбитраж ([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)). +## Черновик во время ревью ТЗ (`S4-spec-review`) + +- Черновик — возможность, а не обязанность: только `track:ask`, только после + того, как ТЗ отправлено и стоит `S4-spec-review`; под `blocked` и `review-4` + не ведётся. Цена — код по непринятому тексту, который после жёлтого или + красного вердикта переделывается + ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)). +- Один checkout, одна локальная ветка `issue/-`; до `S5` не пушится + ни ветка, ни коммиты; статус остаётся `S4`. Комментарий «Черновик:» по + шаблону, черновик занимает слот WIP + ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз), + [§7.2](../../PROCESS.md#72-шаблоны-комментариев), + [§2.6](../../PROCESS.md#26-в-разработке--реализация)). +- Каждый черновой коммит несёт ровно один трейлер `Spec-Draft: sha256:<хеш>` — + строку печатает пакет задачи в разделе «Черновик» + ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)). +- Жёлтый или красный вердикт — черновик остановить: коммиты по прежнему тексту + гейт не примет, переписывать дату автора запрещено; новый черновик — в + следующей эпохе `S4` с новым хешем + ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз), + [§12](../../PROCESS.md#12-запрещено)). +- В ветку черновик попадает только после зелёного ревью ТЗ: `S6-in-progress`, + `git fetch`, ребейз на `origin/dev` (или на `origin/issue/-*`, если ветка + уже была; без force-push), проверка + `node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report`, + push ([§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз), + [§10.2](../../PROCESS.md#102-что-проверяет-process-gatemjs)). + ## Реализация (`S6-in-progress`) - Занятие: `Взял: <роль> · сессия · ветка issue/NN-slug`; WIP — одна @@ -236,9 +266,13 @@ ## Запрещено -- Код без issue или из статуса раньше `S5-ready`; ТЗ после кода (кроме - хотфикса); ревью своей работы; пятый цикл ревью; issue вместо возврата на - правки ([§12](../../PROCESS.md#12-запрещено)). +- Код без issue или из статуса раньше `S5-ready` (кроме черновика + `track:ask` по §11.8); ТЗ после кода (кроме хотфикса); ревью своей работы; + пятый цикл ревью; issue вместо возврата на правки + ([§12](../../PROCESS.md#12-запрещено)). +- Push черновика до `S5`; перенос черновика по устаревшему ТЗ коммитами с + переписанной датой автора ([§12](../../PROCESS.md#12-запрещено), + [§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)). - Попутные правки «раз уж я здесь»; параллельные бэклоги в файлах; force-push в `dev`; закрытие issue до выпуска беты ([§12](../../PROCESS.md#12-запрещено), [§3 п.17](../../PROCESS.md#3-правила)). diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index 9a94df99..14328028 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -36,6 +36,9 @@ вынесенный владельцу, ревьюер снимает и решает по существу. Технический спор автора и ревьюера решается вердиктом, а не владельцем ([§7.1](../../PROCESS.md#71-цепочка)). +- Черновик автора ревьюер ТЗ не читает и доводом не считает: судится тело + issue ([§2.4](../../PROCESS.md#24-тз-на-ревью), + [§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)). ## Код-ревью diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 703452bc..d18a2647 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -42,6 +42,11 @@ import { resolveValidationRange } from './validate-commit-provenance.mjs'; // Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их // читает и хук commit-msg, который судит, нужен ли коммиту трейлер. import { classify } from './change-classes.mjs'; +// #729: черновик `track:ask` судится теми же функциями, что конвейер: зелёный +// документ и «Тело issue» — по якорям review-doc-guard, трек — по меткам +// process-track. Ни один из модулей process-gate.mjs не импортирует. +import { anchorIssueBodyFrom, anchorVerdictFrom } from './review-doc-guard.mjs'; +import { hasTrackLabel, trackFromLabels } from './process-track.mjs'; export { classify }; @@ -111,6 +116,8 @@ export function makeCommit({ release: one('Release'), baselineReviewed: one('Baseline-Reviewed'), baselineReviewedLocal: one('Baseline-Reviewed-Local'), + // #729: все значения, а не первое — «ровно один» судит правило 10. + specDrafts: all('Spec-Draft'), gates: one('Gates'), // null = вызывающий не доказал содержимое diff. Для stable release это // намеренно fail-closed: одного имени разрешённого version source мало. @@ -520,6 +527,96 @@ function statusIndexAt(events, w) { const iso = (ms) => new Date(ms).toISOString(); +// #729: черновик кода во время ревью ТЗ (PROCESS.md §11.8). Статус, в котором +// он пишется, трейлер и его значение — `issueBodyDigest` тела issue, тот же +// хеш, что конвейер пишет в «Тело issue:» документа ревью. +export const DRAFT_STATUS = 'S4-spec-review'; +export const SPEC_DRAFT_VALUE = /^sha256:[0-9a-f]{64}$/; + +// Трек на момент `w`: последнее событие трековой метки (`track:*`, прежние +// `small`/`trivial`) с `at ≤ w`, трек — по одной этой метке. Событий нет — +// `ask`: продуктовая задача без метки (§5.1). +function trackAt(rawEvents, w) { + const marks = rawEvents + .filter((e) => e && e.at && hasTrackLabel([e.label])) + .map((e) => ({ label: e.label, at: Date.parse(e.at) })) + .filter((e) => Number.isFinite(e.at) && e.at <= w) + .sort((a, b) => a.at - b.at); + const last = marks.at(-1); + return last + ? { track: trackFromLabels([last.label]), label: last.label, at: last.at } + : { track: 'ask', label: null, at: null }; +} + +// Эпоха `S4` вокруг события `at`: начало — первое `S4` непрерывной серии +// (повторная постановка, reconcile #555, эпоху заново не начинает), конец — +// первое следующее статусное событие с другой меткой, либо `null`. +function draftEpoch(events, at) { + let first = at; + while (first > 0 && events[first - 1].label === DRAFT_STATUS) first -= 1; + const close = events.slice(at + 1).find((e) => e.label !== DRAFT_STATUS) ?? null; + return { start: events[first].at, close }; +} + +/** + * Зелёный SPEC-REVIEW эпохи `[start, end]` (#729): запись конвейера `green` с + * High 0, есть «Тело issue:», документ добавлен (`addedAt`, дата автора + * добавившего коммита) внутри окна. Из подходящих — наибольший заход. + */ +export function greenSpecReviewOf(docs, nn, start, end) { + const name = new RegExp(`^SPEC-REVIEW-${nn}-r(\\d+)\\.md$`); + let best = null; + for (const doc of docs || []) { + const m = name.exec(String(doc?.name ?? '')); + if (!m) continue; + const recorded = anchorVerdictFrom(doc.text); + if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue; + const body = anchorIssueBodyFrom(doc.text); + if (!body) continue; + const added = Date.parse(doc.addedAt); + if (!Number.isFinite(added) || added < start || added > end) continue; + const round = Number(m[1]); + if (!best || round > best.round) best = { name: doc.name, round, body, addedAt: added }; + } + return best; +} + +// Проверки чернового коммита по порядку (К3 п.2 #729); находка одна — по первой +// невыполненной. `null` — коммит принят как написанный в разрешённой эпохе. +function judgeDraft({ c, nn, events, at, rawEvents, rangeHead, docsOf }) { + const wrote = Date.parse(c.authorDate); + const fail = (msg) => ({ level: 'fail', rule: 10, sha: c.short, msg }); + const lead = `issue #${nn}: коммит класса A написан ${c.authorDate} в ${DRAFT_STATUS}`; + const drafts = c.specDrafts ?? []; + if (drafts.length !== 1 || !SPEC_DRAFT_VALUE.test(drafts[0])) { + return fail(`${lead}: трейлер \`Spec-Draft\` должен быть ровно один, вида \`sha256:<64 hex>\` ` + + `(трейлеров ${drafts.length}) (§11.8)`); + } + const track = trackAt(rawEvents, wrote); + if (track.track !== 'ask') { + return fail(`${lead} на треке ${track.track} (${track.label} с ${iso(track.at)}): ` + + 'черновик разрешён только на `track:ask` (§11.8)'); + } + const epoch = draftEpoch(events, at); + if (!epoch.close || epoch.close.label !== 'S5-ready') { + const closed = epoch.close ? `закрыта ${epoch.close.label} ${iso(epoch.close.at)}` : 'ещё не закрыта'; + return fail(`${lead}: эпоха ${DRAFT_STATUS} с ${iso(epoch.start)} ${closed} — ` + + 'черновик принимается только из эпохи, которую закрыл `S5-ready` (§11.8)'); + } + const green = greenSpecReviewOf(docsOf(), nn, epoch.start, epoch.close.at); + if (!green) { + return fail(`${lead}: эпоха ${iso(epoch.start)}…${iso(epoch.close.at)} — зелёный \`SPEC-REVIEW-${nn}-r*\` ` + + `этой эпохи не найден ни в ${rangeHead}, ни в origin/dev — \`git fetch origin dev\` и повторить (§11.8)`); + } + const draft = drafts[0].slice('sha256:'.length); + if (draft !== green.body) { + return fail(`${lead}: трейлер \`Spec-Draft\` sha256:${draft.slice(0, 12)} не равен «Тело issue» ` + + `sha256:${green.body.slice(0, 12)} из ${green.name} — ТЗ правилось — работа переделывается после S5, ` + + 'дата автора не переписывается (§12)'); + } + return null; +} + // 10. DoR по моменту коммита (#311, #738). Правило 8 читает ТЕКУЩУЮ метку // issue: нарушение «код написан вне статуса разработки» становится невидимым, // как только статус штатно продвигается. Здесь статус задачи сверяется с @@ -532,8 +629,15 @@ const iso = (ms) => new Date(ms).toISOString(); // authorDate переживает ребейзы конвейера — окно нарушения не закрывается. // Проверка вторичная к правилу 8, поэтому недоступный timeline — warn, а не // fail: основная fail-closed проверка статуса остаётся за правилом 8. +// +// #729: одно исключение поверх эпох — черновик `track:ask` (§11.8). Коммит, +// написанный в `S4-spec-review` с трейлером `Spec-Draft`, принимается, если +// эпоху `S4` закрыл `S5-ready`, трек на момент написания — `ask`, а трейлер +// равен «Тело issue» зелёного SPEC-REVIEW этой эпохи. Документы читает +// `specReviews` (`{ head, read(nn) }`, см. gitSpecReviewReader); не передан — +// исключение выключено, и находки с текстами равны #738. export function checkCommitEraStatuses( - commits, timelineRunner, { allowed = ALLOWED_STATUS } = {}, + commits, timelineRunner, { allowed = ALLOWED_STATUS, specReviews = null } = {}, ) { const out = []; const byIssue = new Map(); @@ -567,11 +671,40 @@ export function checkCommitEraStatuses( }); continue; } + // #729: документы issue читаются один раз и только для чернового коммита. + let issueDocs = null; + const docsOf = () => { + if (issueDocs === null) issueDocs = specReviews.read(nn) ?? []; + return issueDocs; + }; for (const c of list) { const wrote = Date.parse(c.authorDate); if (!Number.isFinite(wrote)) continue; const at = statusIndexAt(events, wrote); - if (at >= 0 && events[at].ready) continue; + const drafts = c.specDrafts ?? []; + if (at >= 0 && events[at].ready) { + // #729: коммит законен по #738, трейлер — шум; вероятно, он переехал + // вместе с кодом при переносе черновика. + if (specReviews && drafts.length) { + out.push({ + level: 'warn', rule: 10, sha: c.short, + msg: `issue #${nn}: трейлер \`Spec-Draft\` на коммите, написанном в ${events[at].label}: ` + + `черновиком считается только коммит из \`${DRAFT_STATUS}\` (§11.8)`, + }); + } + continue; + } + const inDraftStatus = Boolean(specReviews) && at >= 0 && events[at].label === DRAFT_STATUS; + if (inDraftStatus && drafts.length) { + const finding = judgeDraft({ + c, nn, events, at, rawEvents: r.events, rangeHead: specReviews.head ?? '', docsOf, + }); + if (finding) out.push(finding); + continue; + } + const tail = inDraftStatus + ? `; написан в \`${DRAFT_STATUS}\` — черновик \`track:ask\` принимается только с трейлером \`Spec-Draft\` (§11.8)` + : ''; let lastReady = -1; for (let i = 0; i < at; i += 1) if (events[i].ready) lastReady = i; if (lastReady < 0) { @@ -579,7 +712,7 @@ export function checkCommitEraStatuses( level: 'fail', rule: 10, sha: c.short, msg: `issue #${nn}: коммит класса A написан ${c.authorDate}, ` + `до первого достижения задачей статуса из ${allowed.join('/')} ` - + `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)`, + + `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)${tail}`, }); continue; } @@ -591,7 +724,7 @@ export function checkCommitEraStatuses( level: 'fail', rule: 10, sha: c.short, msg: `issue #${nn}: коммит класса A написан ${c.authorDate} в ${events[at].label} — ` + `после возврата ${iso(returned.at)} и до повторного «Готово к разработке» ` - + `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)`, + + `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)${tail}`, }); } } @@ -640,6 +773,54 @@ function ghTimelineRunner(nwo, bin) { }; } +/** + * Документы SPEC-REVIEW из git для исключения #729 (§11.8): вершина + * проверяемого диапазона и `refs/remotes/origin/dev`, если она есть. На issue + * SPEC-REVIEW лежит либо в ветке задачи, либо прямо в `dev` (ветки до `S5` у + * `ask` обычно нет), поэтому нужны обе ссылки. `addedAt` — дата автора + * коммита, добавившего путь на той ссылке, где документ найден: она переживает + * ребейзы конвейера. Одно имя на обеих ссылках — одна запись. Чтение ленивое: + * ни одной команды git, пока правило 10 не спросило документы issue, и один + * раз на issue. + */ +export function gitSpecReviewReader({ repo = process.cwd(), head = 'HEAD', run = spawnSync } = {}) { + const git = (args) => { + const r = run('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return r && r.status === 0 ? r.stdout : null; + }; + let refs = null; + const cache = new Map(); + return { + head, + read(nn) { + if (cache.has(nn)) return cache.get(nn); + if (!refs) { + refs = [head]; + if (git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/dev']) !== null) refs.push('refs/remotes/origin/dev'); + } + const name = new RegExp(`^docs/reviews/(SPEC-REVIEW-${nn}-r\\d+\\.md)$`); + const byName = new Map(); + for (const ref of refs) { + const listing = git(['ls-tree', '--name-only', ref, 'docs/reviews/']); + if (listing === null) continue; + for (const path of listing.split('\n').map((s) => s.trim())) { + const m = name.exec(path); + if (!m || byName.has(m[1])) continue; + byName.set(m[1], { + name: m[1], + ref, + text: git(['show', `${ref}:${path}`]) ?? '', + addedAt: (git(['log', '-1', '--diff-filter=A', '--format=%aI', ref, '--', path]) ?? '').trim(), + }); + } + } + const docs = [...byName.values()]; + cache.set(nn, docs); + return docs; + }, + }; +} + function ghRunner(nwo, bin) { return (nn) => { const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels,body'], @@ -833,10 +1014,13 @@ function main(argv) { process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh', ); const timelineCache = new Map(); + // #729: читатель SPEC-REVIEW передаётся всегда; git он трогает, только + // когда в диапазоне есть черновой коммит. Вершина — правая часть --range. + const rangeHead = /^(.*?)\.\.\.?(.+)$/.exec(range)?.[2] ?? range; findings.push(...checkCommitEraStatuses(statusCommits, (nn) => { if (!timelineCache.has(nn)) timelineCache.set(nn, timelineRunner(nn)); return timelineCache.get(nn); - }, { allowed })); + }, { allowed, specReviews: gitSpecReviewReader({ repo, head: rangeHead }) })); } labelsOf = (nn) => { const r = cached(nn); diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index e1746f8e..eb1c1703 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -8,10 +8,10 @@ // Скрипт делает эту сборку детерминированно и печатает один markdown-пакет: // // issue · статус и трек · трек: основание, лимит, ребейз (#707) · что можно -// делать в этом статусе · решения владельца · материал (ветка, SHA, база, -// Validate) · следующий шаг по ветке · риск по участкам · обязательные -// проверки · changelog и визуальное свидетельство · предыдущий вердикт · -// AC → свидетель · непроверенное +// делать в этом статусе · черновик во время ревью ТЗ (#729) · решения +// владельца · материал (ветка, SHA, база, Validate) · следующий шаг по +// ветке · риск по участкам · обязательные проверки · changelog и визуальное +// свидетельство · предыдущий вердикт · AC → свидетель · непроверенное // // Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не // решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами. @@ -20,7 +20,9 @@ import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; -import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs'; +import { + anchorIssueBodyFrom, anchorTreeFrom, anchorVerdictFrom, issueBodyDigest, verdictDeclaration, +} from './review-doc-guard.mjs'; import { classify } from './process-gate.mjs'; import { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin, @@ -30,18 +32,28 @@ import { selectSmokes } from './smoke-select.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; /** Что разрешено в статусе — по PROCESS.md, без домыслов. */ -export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false } = {}) { +export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false, track = null } = {}) { const blocked = labels.includes('blocked'); const exhausted = labels.includes('review-4'); const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status); + // #729: на `track:ask` в `S4-spec-review` локальный черновик разрешён (§11.8), + // в ветку код по-прежнему только после `S5`; под blocked/review-4 — нет. + const specReview = !infrastructure && status === 'S4-spec-review' && track === 'ask'; + const draft = specReview && !blocked && !exhausted; const lines = []; if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать'); if (blocked) lines.push('blocked: работа стоит, ждём внешнего решения — коммиты по задаче гейт не пропустит'); - lines.push(code - ? 'продуктовый код трогать МОЖНО (правило №1)' - : infrastructure - ? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)' - : 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)'); + if (draft) { + lines.push('продуктовый код в ветку — НЕЛЬЗЯ до `S5` (правило №1)'); + lines.push('черновик локально — МОЖНО (§11.8): ветка `issue/NN-slug` не пушится, трейлер — в разделе «Черновик»'); + } else { + lines.push(code + ? 'продуктовый код трогать МОЖНО (правило №1)' + : infrastructure + ? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)' + : 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)'); + } + if (specReview && !draft) lines.push(`черновик не ведётся (§11.8): ${exhausted ? 'review-4' : 'blocked'}`); if (infrastructureHint) { lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)'); } @@ -130,12 +142,64 @@ export function branchIsInfrastructure(changedFiles = []) { return material.length > 0 && !material.some((name) => classify(name) === 'A'); } +/** + * Последний зелёный SPEC-REVIEW задачи (#729): запись конвейера `green` с + * High 0 и строкой «Тело issue:» — те же якоря, что читает гейт. Из + * подходящих — наибольший заход; нет — `null`. + */ +export function latestGreenSpecReview(specDocs = [], number = null) { + const name = new RegExp(`^SPEC-REVIEW-${number ?? '\\d+'}-r(\\d+)\\.md$`); + let best = null; + for (const doc of specDocs) { + const m = name.exec(String(doc?.name ?? '')); + if (!m) continue; + const recorded = anchorVerdictFrom(doc.text); + if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue; + const body = anchorIssueBodyFrom(doc.text); + if (!body) continue; + const round = Number(m[1]); + if (!best || round > best.round) best = { doc: doc.name, round, body }; + } + return best; +} + +/** + * Черновик кода во время ревью ТЗ (#729, PROCESS.md §11.8): можно ли вести его + * сейчас, строка трейлера и зелёное ревью ТЗ, с которым гейт сверит черновые + * коммиты. Хеш — `issueBodyDigest`, та же функция, что пишет «Тело issue:» в + * якорь документа ревью. + */ +export function specDraftState({ status = null, labels = [], track = null, body = '', specDocs = [], number = null } = {}) { + const bodyNow = issueBodyDigest(body ?? ''); + const stop = labels.includes('review-4') ? 'review-4' : labels.includes('blocked') ? 'blocked' : null; + let allowed = false; + let reason; + if (status !== 'S4-spec-review') reason = `статус ${status || 'без S-метки'}: черновик ведётся только в S4-spec-review (§11.8)`; + else if (track !== 'ask') reason = `трек ${track}: черновик только на track:ask (§11.8)`; + else if (stop) reason = `${stop}: черновик не ведётся (§11.8)`; + else { + allowed = true; + reason = 'track:ask в S4-spec-review: черновик можно вести локально (§11.8)'; + } + const green = latestGreenSpecReview(specDocs, number); + return { + allowed, + reason, + trailer: allowed ? `Spec-Draft: sha256:${bodyNow}` : null, + green, + bodyNow, + bodyChanged: green ? green.body !== bodyNow : null, + }; +} + const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready']; // Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs. // С #707 оттуда же основание трека, лимит циклов, политика ребейза и риск по // изменённым участкам: пакет не держит своей копии правила. export { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, trackFromLabels, trackOrigin }; +// Хеш тела issue — одна функция с конвейером и гейтом (#729): review-doc-guard.mjs. +export { issueBodyDigest }; /** Зеркала junction limits: правка любого требует parity (§8, #548). */ export const JUNCTION_MIRRORS = Object.freeze([ @@ -254,6 +318,7 @@ export function productFlowEvidence({ status = null, labels = [], issue = {}, sp export function buildPacket(inputs) { const { issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null, + specDocs = [], } = inputs; const status = STATUS_LABELS.find((l) => labels.includes(l)) || null; // Трек сначала определяется статусом и историей issue (#632): прошедшая @@ -300,7 +365,13 @@ export function buildPacket(inputs) { const unverified = acs.filter((a) => a.evidence.startsWith('без записи')); const packet = { issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url }, - status, track, trackDetail, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }), + status, track, trackDetail, labels, productFlow, + rights: rightsFor(status, labels, { infrastructure, infrastructureHint, track: infrastructure ? null : origin.track }), + // #729: свой вход `specDocs` — lastVerdict, AC → свидетель и признаки + // продуктового потока по-прежнему читают только reviewDocs. + specDraft: specDraftState({ + status, labels, track: infrastructure ? null : origin.track, body: issue.body, specDocs, number: issue.number, + }), decisions: ownerDecisions(comments, owner), material: branch ? { branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind, @@ -337,6 +408,26 @@ export function renderPacket(p) { L.push('## Права и следующий шаг'); for (const r of p.rights) L.push(`- ${r}`); L.push(''); + const draft = p.specDraft; + const askTrack = p.trackDetail?.track === 'ask'; + if (draft && askTrack && p.status === 'S4-spec-review') { + L.push('## Черновик (#729)'); + if (draft.allowed) { + L.push(`- трейлер каждого чернового коммита: \`${draft.trailer}\``); + L.push('- ветку не пушить до `S5`; `S6` — после зелёного ревью ТЗ; комментарий «Черновик:» (§7.2); черновик занимает слот WIP'); + L.push('- жёлтый или красный вердикт — черновик остановить: коммиты по прежнему тексту гейт не примет'); + } else L.push(`- ${draft.reason}`); + L.push(''); + } + if (draft?.green && askTrack && (p.status === 'S5-ready' || p.status === 'S6-in-progress')) { + L.push('## Черновик (#729)'); + L.push(`- зелёное ревью ТЗ: \`${draft.green.doc}\` · тело \`sha256:${draft.green.body.slice(0, 12)}\` — черновые коммиты принимаются с этим хешем`); + L.push(draft.bodyChanged + ? '- тело issue сейчас: изменилось после зелёного ревью ТЗ (ревьюер кода получит находку, #517)' + : '- тело issue сейчас: совпадает'); + L.push('- проверка до push: `node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report`'); + L.push(''); + } L.push('## Решения владельца (свежие)'); if (!p.decisions.length) L.push('- не найдены (комментарии владельца со словами решения отсутствуют)'); for (const d of p.decisions) L.push(`- ${d.at?.slice(0, 16) || ''} — ${d.head}${d.url ? ` (${d.url})` : ''}`); @@ -449,6 +540,21 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr changedFiles, diff, commits, smokes, mergeClean: merge.clean, conflicts: merge.conflicts, }; } + // #729: SPEC-REVIEW — отдельный вход `specDocs` из ветки на origin и из + // `origin/dev`: на ask документ ревью ТЗ обычно ложится прямо в dev. Источник + // reviewDocs (lastVerdict, AC → свидетель) не меняется. + const specByName = new Map(); + for (const ref of [...(refs[0] ? [`origin/${refs[0]}`] : []), 'origin/dev']) { + let names = []; + try { + names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n') + .filter((n) => new RegExp(`^SPEC-REVIEW-${number}-r\\d+\\.md$`).test(n)); + } catch { continue; } + for (const n of names) { + if (!specByName.has(n)) specByName.set(n, { name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) }); + } + } + const specDocs = [...specByName.values()].sort((a, b) => a.name.localeCompare(b.name)); let validate = null; if (branch) { try { @@ -459,7 +565,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr : runs[0] ? { status: `красный (${runs[0].conclusion})`, url: runs[0].url } : { status: 'прогона нет' }; } catch { validate = { status: 'неизвестно (gh run list недоступен)' }; } } - return { issue: view, labels, comments, owner, branch, specs, reviewDocs, validate }; + return { issue: view, labels, comments, owner, branch, specs, reviewDocs, specDocs, validate }; } if (isMainModule(import.meta.url)) { diff --git a/test/pre-push-gate.test.mjs b/test/pre-push-gate.test.mjs index 876aea00..dc0a1004 100644 --- a/test/pre-push-gate.test.mjs +++ b/test/pre-push-gate.test.mjs @@ -139,6 +139,11 @@ const HOOK_FILES = [ 'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения 'scripts/bundle-tree.mjs', 'scripts/spawn-portable.mjs', + // #729: правило 10 судит черновик якорями review-doc-guard и треком process-track. + 'scripts/review-doc-guard.mjs', + 'scripts/process-track.mjs', + 'scripts/change-risk.mjs', + 'scripts/review-result-gate.mjs', ]; // Заглушка набора: код выхода и журнал вызовов задаёт тест. const GATE_STUB = `import { appendFileSync } from 'node:fs'; diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs index b4be2827..d866f6d1 100644 --- a/test/process-digests.test.mjs +++ b/test/process-digests.test.mjs @@ -77,6 +77,9 @@ const KEY_RULES = { ['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'], ['12-запрещено', 'force-push в `dev`'], ['12-запрещено', 'попутные правки «раз уж я здесь»'], + // #729: черновик track:ask во время ревью ТЗ — исключение из правила №1. + ['118-черновик-кода-во-время-ревью-тз', 'черновик — возможность, а не обязанность'], + ['118-черновик-кода-во-время-ревью-тз', 'в ветку черновик попадает только после зелёного ревью ТЗ'], ], 'docs/process/REVIEWER.md': [ ['27-код-ревью', 'Ревьюер ≠ исполнитель'], @@ -107,6 +110,8 @@ const KEY_RULES = { ['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'], // #727: ночное пакетное ревью ship переиспользуется гейтом беты по патч-набору. ['117-пакетное-ревью-ship-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'], + // #729: готовый черновик не довод за непринятое ТЗ. + ['24-тз-на-ревью', 'ревьюер ТЗ не читает и доводом не считает'], ], }; diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index d1db6c38..eaa69077 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -20,6 +20,7 @@ import { checkSpecs, clampIssueBranchRange, classify, + gitSpecReviewReader, commitsNeedingIssueStatus, commitsNeedingTargetValidation, commitsUnderRuleOne, @@ -31,6 +32,7 @@ import { FS, RS, } from '../scripts/process-gate.mjs'; +import { issueBodyDigest, materialAnchorBlock } from '../scripts/review-doc-guard.mjs'; const commit = (subject, body, files) => makeCommit({ sha: 'deadbeefcafe', subject, body, files }); const rules = (findings) => findings.filter((f) => f.level === 'fail').map((f) => f.rule); @@ -1058,3 +1060,355 @@ test('#738 AC2: rule 10 keeps the old verdicts on ready-only timelines and ignor // …and equal to the repeated S5 is already ready. assert.deepEqual(era(T6), []); }); + +// #729: черновик `track:ask` во время ревью ТЗ (PROCESS.md §11.8) — одно +// исключение поверх эпох #738. Общая шкала AC1: `track:ask` и `S3-spec` 09:00, +// `S4-spec-review` 10:00, зелёный SPEC-REVIEW-729-r1 (тело H) добавлен 10:30, +// `S5-ready` 10:40, `S6-in-progress` 10:45. Коммит — класс A (`src/a.ts`). +const DAY = '2026-09-20'; +const hm = (time) => `${DAY}T${time}:00.000Z`; +const BODY_H = '## ТЗ\n\n- AC1: черновик принимается'; +const BH = issueBodyDigest(BODY_H); +const BH_OTHER = issueBodyDigest('## ТЗ\n\n- AC1: черновик по другому тексту'); +const BH1 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 1'); +const BH2 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 2'); +const BH3 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 3'); +const sha256 = (hex) => `sha256:${hex}`; +const draftCode = (time, { trailers = [sha256(BH)], files = ['src/a.ts'], sha = 'd'.repeat(40) } = {}) => makeCommit({ + sha, subject: 'feat: draft (#729)', files, authorDate: hm(time), + body: ['Issue: #729', 'User-Visible: no', ...trailers.map((v) => `Spec-Draft: ${v}`)].join('\n'), +}); +const specDoc = (round, added, { verdict = 'green', high = 0, body = BH } = {}) => ({ + name: `SPEC-REVIEW-729-r${round}.md`, + addedAt: hm(added), + text: `# SPEC-REVIEW-729-r${round}\n\n${materialAnchorBlock({ verdict, high, issueBody: body ?? undefined })}`, +}); +const docsReader = (docs) => { + const reader = { head: 'HEAD', reads: 0, read: () => { reader.reads += 1; return docs; } }; + return reader; +}; +const ev = (label, time) => ({ label, at: hm(time) }); +const DRAFT_ROUTE = [ + ev('track:ask', '09:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00'), + ev('S5-ready', '10:40'), ev('S6-in-progress', '10:45'), +]; +const DRAFT_DOCS = [specDoc(1, '10:30')]; +const drafted = (commit, { events = DRAFT_ROUTE, docs = DRAFT_DOCS, allowed } = {}) => + checkCommitEraStatuses([commit], eraTimeline(events), { + specReviews: docsReader(docs), ...(allowed ? { allowed } : {}), + }); +const failOf = (findings) => { + assert.equal(findings.length, 1, JSON.stringify(findings)); + assert.equal(findings[0].level, 'fail'); + assert.equal(findings[0].rule, 10); + return findings[0].msg; +}; + +test('#729 AC1: a draft written in S4 with the accepted body hash is accepted, others are refused', () => { + // Чем краснеет: на коде #738 этот коммит — fail «код раньше «Готово к разработке»». + assert.deepEqual(drafted(draftCode('10:10')), []); + + const bare = failOf(drafted(draftCode('10:10', { trailers: [] }))); + assert.match(bare, /S4-spec-review/); + assert.match(bare, /Spec-Draft/); + + const stale = failOf(drafted(draftCode('10:10', { trailers: [sha256(BH_OTHER)] }))); + assert.ok(stale.includes(BH_OTHER.slice(0, 12)), stale); + assert.ok(stale.includes(BH.slice(0, 12)), stale); + assert.ok(stale.includes('SPEC-REVIEW-729-r1'), stale); + assert.match(stale, /ТЗ правилось/); + + // Written in the S3 era: the plain #738 text, the trailer buys nothing. + const inS3 = draftCode('09:30'); + const s3 = failOf(drafted(inS3)); + assert.deepEqual(drafted(inS3), checkCommitEraStatuses([inS3], eraTimeline(DRAFT_ROUTE))); + assert.match(s3, /до первого достижения задачей статуса из/); + assert.doesNotMatch(s3, /§11\.8/); +}); + +test('#729 AC2: the S4 epoch and its round decide — an epoch closed by S3 or S6, or still open, is refused', () => { + const rounds = [ + ev('track:ask', '09:00'), ev('S3-spec', '09:00'), + ev('S4-spec-review', '10:00'), ev('S3-spec', '10:40'), + ev('S4-spec-review', '11:00'), ev('S5-ready', '11:40'), + ]; + const docs = [specDoc(1, '10:30', { verdict: 'yellow', body: BH1 }), specDoc(2, '11:30', { body: BH2 })]; + for (const hash of [BH1, BH2]) { + const closedByS3 = failOf(drafted(draftCode('10:10', { trailers: [sha256(hash)] }), { events: rounds, docs })); + assert.match(closedByS3, /S3-spec/); + assert.ok(closedByS3.includes(hm('10:40')), closedByS3); + } + assert.deepEqual(drafted(draftCode('11:10', { trailers: [sha256(BH2)] }), { events: rounds, docs }), []); + const oldRound = failOf(drafted(draftCode('11:10', { trailers: [sha256(BH1)] }), { events: rounds, docs })); + assert.ok(oldRound.includes('SPEC-REVIEW-729-r2'), oldRound); + + // A repeated S4 (reconcile #555) neither closes the epoch nor restarts it. + const repeated = [...rounds.slice(0, 5), ev('S4-spec-review', '11:20'), rounds[5]]; + assert.deepEqual(drafted(draftCode('11:05', { trailers: [sha256(BH2)] }), { events: repeated, docs }), []); + const earlyDoc = [specDoc(2, '11:10', { body: BH2 })]; + assert.deepEqual(drafted(draftCode('11:25', { trailers: [sha256(BH2)] }), { events: repeated, docs: earlyDoc }), []); + + // The epoch is not closed yet. + const open = [ev('S5-ready', '08:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00')]; + assert.match(failOf(drafted(draftCode('10:10'), { events: open })), /ещё не закрыта/); + + // No `allowed` event at all — the #738 warn, the exception is never reached. + const neverReady = DRAFT_ROUTE.slice(0, 3); + const blind = drafted(draftCode('10:10'), { events: neverReady }); + assert.equal(blind.length, 1); + assert.equal(blind[0].level, 'warn'); + assert.deepEqual(blind, checkCommitEraStatuses([draftCode('10:10')], eraTimeline(neverReady))); + + // S4 → S6 without S5. + const skipped = [...DRAFT_ROUTE.slice(0, 3), ev('S6-in-progress', '10:45')]; + const noS5 = failOf(drafted(draftCode('10:10'), { events: skipped })); + assert.match(noS5, /закрыта S6-in-progress/); + assert.ok(noS5.includes(hm('10:45')), noS5); +}); + +test('#729 AC3: the track at the author date must be ask, and the trailer format is exact', () => { + const withTrack = (label, extra = []) => [ev(label, '09:00'), ...DRAFT_ROUTE.slice(1), ...extra]; + const onShow = failOf(drafted(draftCode('10:10'), { events: withTrack('track:show') })); + assert.match(onShow, /на треке show/); + assert.match(onShow, /только на `track:ask`/); + + const raisedLater = withTrack('track:ask', [ev('track:show', '10:05')]); + assert.deepEqual(drafted(draftCode('10:02'), { events: raisedLater }), []); + assert.match(failOf(drafted(draftCode('10:10'), { events: raisedLater })), /на треке show/); + + assert.deepEqual(drafted(draftCode('10:10'), { events: DRAFT_ROUTE.slice(1) }), [], 'no track events read as ask'); + assert.match(failOf(drafted(draftCode('10:10'), { events: withTrack('small') })), /на треке show/); + + for (const trailers of [ + [sha256(BH.slice(0, 63))], + [sha256(BH.toUpperCase())], + [BH], + [sha256(BH), sha256(BH)], + ]) { + const format = failOf(drafted(draftCode('10:10', { trailers }))); + assert.match(format, /ровно один, вида `sha256:<64 hex>`/, trailers.join(' + ')); + } +}); + +test('#729 AC4: the draft is matched with the green spec review of its own epoch', () => { + const twoEpochs = [ + ev('track:ask', '09:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00'), + ev('S5-ready', '10:40'), ev('S6-in-progress', '10:50'), ev('S7-code-review', '11:00'), + ev('S3-spec', '12:00'), ev('S4-spec-review', '13:00'), ev('S5-ready', '13:40'), + ]; + const docs = [specDoc(1, '10:30', { body: BH1 }), specDoc(3, '13:30', { body: BH3 })]; + const at = (time, hash) => drafted(draftCode(time, { trailers: [sha256(hash)] }), { events: twoEpochs, docs }); + assert.deepEqual(at('10:10', BH1), []); + assert.ok(failOf(at('10:10', BH3)).includes('SPEC-REVIEW-729-r1')); + assert.deepEqual(at('13:10', BH3), []); + assert.ok(failOf(at('13:10', BH1)).includes('SPEC-REVIEW-729-r3')); + + for (const [why, doc] of [ + ['yellow', specDoc(1, '10:30', { verdict: 'yellow' })], + ['High 1', specDoc(1, '10:30', { high: 1 })], + ['no body anchor', specDoc(1, '10:30', { body: null })], + ['added after S5', specDoc(1, '10:45')], + ]) { + const missing = failOf(drafted(draftCode('10:10'), { docs: [doc] })); + assert.match(missing, /зелёный `SPEC-REVIEW-729-r\*` этой эпохи не найден ни в HEAD, ни в origin\/dev/, why); + assert.match(missing, /git fetch origin dev/, why); + assert.ok(missing.includes(`${hm('10:00')}…${hm('10:40')}`), why); + } + + // Two green documents in one epoch: the higher round wins. + const twoGreen = [specDoc(1, '10:20', { body: BH }), specDoc(2, '10:35', { body: BH2 })]; + assert.deepEqual(drafted(draftCode('10:10', { trailers: [sha256(BH2)] }), { docs: twoGreen }), []); + assert.ok(failOf(drafted(draftCode('10:10'), { docs: twoGreen })).includes('SPEC-REVIEW-729-r2')); +}); + +test('#729 AC5: without a reader rule 10 is exactly #738; class B is ignored; a trailer outside S4 only warns', () => { + const strip = (c) => ({ ...c, specDrafts: [] }); + const cases = [ + [draftCode('10:10'), DRAFT_ROUTE], + [draftCode('10:10', { trailers: [] }), DRAFT_ROUTE], + [draftCode('10:10', { trailers: [sha256(BH_OTHER)] }), DRAFT_ROUTE], + [draftCode('09:30'), DRAFT_ROUTE], + [draftCode('10:10', { trailers: [BH] }), DRAFT_ROUTE], + [draftCode('10:10'), [ev('S5-ready', '08:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00')]], + [draftCode('10:10'), [...DRAFT_ROUTE.slice(0, 3), ev('S6-in-progress', '10:45')]], + [draftCode('10:50'), DRAFT_ROUTE], + ]; + for (const [c, events] of cases) { + const plain = checkCommitEraStatuses([c], eraTimeline(events)); + assert.deepEqual(plain, checkCommitEraStatuses([strip(c)], eraTimeline(events)), c.authorDate); + for (const f of plain) assert.doesNotMatch(f.msg, /Spec-Draft|§11\.8/); + } + assert.deepEqual(checkCommitEraStatuses([draftCode('10:10')], eraTimeline(DRAFT_ROUTE)), [{ + level: 'fail', rule: 10, sha: 'dddddddd', + msg: `issue #729: коммит класса A написан ${hm('10:10')}, до первого достижения задачей статуса из ` + + `${ALLOWED_STATUS.join('/')} (${hm('10:40')}) — код раньше «Готово к разработке» (§12)`, + }]); + + // Class B with the trailer in the S4 epoch is not rule 10's business. + assert.deepEqual(drafted(draftCode('10:10', { files: ['scripts/a.mjs'], trailers: [sha256(BH_OTHER)] })), []); + + // A class A commit written in S6 with the trailer: legal by #738, one warn. + const late = drafted(draftCode('10:50')); + assert.equal(late.length, 1); + assert.equal(late[0].level, 'warn'); + assert.equal(late[0].rule, 10); + assert.match(late[0].msg, /написанном в S6-in-progress/); + + // The strict set gives the same findings (the #738 text names the set itself). + const named = (findings, set) => findings.map((f) => ({ ...f, msg: f.msg.replace(set.join('/'), '') })); + for (const c of [draftCode('10:10'), draftCode('10:10', { trailers: [sha256(BH_OTHER)] }), draftCode('09:30'), draftCode('10:50')]) { + assert.deepEqual(named(drafted(c, { allowed: STRICT_STATUS }), STRICT_STATUS), named(drafted(c), ALLOWED_STATUS), c.authorDate); + } + + // Documents are read lazily, once per issue, only for a draft commit. + const reader = docsReader(DRAFT_DOCS); + checkCommitEraStatuses([draftCode('10:10', { trailers: [] }), draftCode('10:50')], eraTimeline(DRAFT_ROUTE), { specReviews: reader }); + assert.equal(reader.reads, 0); + checkCommitEraStatuses([draftCode('10:10'), draftCode('10:20', { sha: 'e'.repeat(40) })], eraTimeline(DRAFT_ROUTE), { specReviews: reader }); + assert.equal(reader.reads, 1); +}); + +test('#729 AC7: one hash function — the pipeline anchor and the trailer share the normalisation', () => { + const doc = { name: 'SPEC-REVIEW-729-r1.md', addedAt: hm('10:30'), text: materialAnchorBlock({ issueBody: issueBodyDigest(BODY_H), verdict: 'green', high: 0 }) }; + const crlf = sha256(issueBodyDigest(`${BODY_H.replace(/\n/g, '\r\n')} \n`)); + assert.deepEqual(drafted(draftCode('10:10', { trailers: [crlf] }), { docs: [doc] }), []); + // The material step still hashes the body with the same function. + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); + assert.match(workflow, /m\.issueBodyDigest\(/); +}); + +// AC6: читатель SPEC-REVIEW из git и проводка CLI — временный репозиторий и +// подставной gh, как тест #562. +const gitMissing = (t) => { + if (spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0) return false; + t.skip('git недоступен'); + return true; +}; +const gitRepo = (prefix) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + const git = (args, env = {}) => { + const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env: { ...process.env, ...env } }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + const write = (rel, text) => { + const full = join(dir, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, text); + }; + // Дата коммиттера намеренно другая: судится дата автора (`%aI`). + const commitAt = (time, message) => { + git(['add', '-A']); + git(['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null', + 'commit', '-q', '-m', message], { GIT_AUTHOR_DATE: hm(time), GIT_COMMITTER_DATE: hm('23:59') }); + return git(['rev-parse', 'HEAD']); + }; + return { dir, git, write, commitAt }; +}; +const reviewDoc = (round, body = BH) => `# SPEC-REVIEW-729-r${round}\n\n${materialAnchorBlock({ verdict: 'green', high: 0, issueBody: body })}`; + +test('#729 AC6: the git reader takes SPEC-REVIEW from the range head and origin/dev, one record per name', (t) => { + if (gitMissing(t)) return; + const { dir, git, write, commitAt } = gitRepo('hp-gate-729-reader-'); + try { + git(['init', '-q', '-b', 'dev']); + write('README.md', 'base\n'); + commitAt('08:00', 'Base'); + write('docs/reviews/SPEC-REVIEW-729-r1.md', reviewDoc(1)); + write('docs/reviews/SPEC-REVIEW-7290-r1.md', reviewDoc(1)); + write('docs/reviews/CODE-REVIEW-729-r1.md', reviewDoc(1)); + const shared = commitAt('10:30', 'docs: review document for #729'); + write('docs/reviews/SPEC-REVIEW-729-r2.md', reviewDoc(2)); + git(['update-ref', 'refs/remotes/origin/dev', commitAt('10:35', 'docs: review document for #729')]); + git(['checkout', '-q', '-b', 'issue/729-draft', shared]); + write('docs/reviews/SPEC-REVIEW-729-r3.md', reviewDoc(3)); + commitAt('10:50', 'docs: review document for #729'); + + let calls = 0; + const counted = (...args) => { calls += 1; return spawnSync(...args); }; + const reader = gitSpecReviewReader({ repo: dir, head: 'HEAD', run: counted }); + assert.equal(calls, 0, 'nothing is read before rule 10 asks'); + const docs = reader.read('729'); + assert.deepEqual(docs.map((d) => d.name).sort(), ['SPEC-REVIEW-729-r1.md', 'SPEC-REVIEW-729-r2.md', 'SPEC-REVIEW-729-r3.md']); + const byName = Object.fromEntries(docs.map((d) => [d.name, d])); + assert.equal(byName['SPEC-REVIEW-729-r1.md'].ref, 'HEAD', 'a name on both refs is one record'); + assert.equal(byName['SPEC-REVIEW-729-r2.md'].ref, 'refs/remotes/origin/dev'); + assert.equal(byName['SPEC-REVIEW-729-r3.md'].ref, 'HEAD'); + assert.equal(Date.parse(byName['SPEC-REVIEW-729-r1.md'].addedAt), Date.parse(hm('10:30'))); + assert.equal(Date.parse(byName['SPEC-REVIEW-729-r2.md'].addedAt), Date.parse(hm('10:35'))); + assert.equal(Date.parse(byName['SPEC-REVIEW-729-r3.md'].addedAt), Date.parse(hm('10:50'))); + assert.equal(byName['SPEC-REVIEW-729-r3.md'].text, reviewDoc(3)); + const after = calls; + reader.read('729'); + assert.equal(calls, after, 'read once per issue'); + + // Without origin/dev only the head is read. + git(['update-ref', '-d', 'refs/remotes/origin/dev']); + assert.deepEqual(gitSpecReviewReader({ repo: dir, head: 'HEAD' }).read('729').map((d) => d.name).sort(), + ['SPEC-REVIEW-729-r1.md', 'SPEC-REVIEW-729-r3.md']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('#729 AC6: the CLI with --issues accepts a matching draft and refuses a stale or unproven one', (t) => { + if (process.platform === 'win32') { + t.skip('нужен исполняемый stub gh — прогон в Linux CI'); + return; + } + if (gitMissing(t)) return; + const { dir, git, write, commitAt } = gitRepo('hp-gate-729-cli-'); + const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url)); + const ghStub = join(dir, 'gh-stub.mjs'); + const runGate = (range) => spawnSync(process.execPath, [gate, '--repo', dir, '--range', range, '--issues'], + { encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub } }); + const draftMessage = (hash) => `feat: draft (#729)\n\nIssue: #729\nUser-Visible: no\nSpec-Draft: sha256:${hash}`; + + try { + const timeline = DRAFT_ROUTE.map((e) => ({ label: e.label, at: e.at })); + writeFileSync(ghStub, '#!/usr/bin/env node\n' + + 'const argv = process.argv.slice(2);\n' + + `if (argv[0] === 'api' && /\\/issues\\/729\\/timeline$/.test(argv[1])) process.stdout.write(${JSON.stringify(JSON.stringify(timeline))} + '\\n');\n` + + `else if (argv[0] === 'issue' && argv[1] === 'view') process.stdout.write(${JSON.stringify(JSON.stringify({ + number: 729, state: 'OPEN', labels: [{ name: 'S6-in-progress' }, { name: 'track:ask' }], body: BODY_H, + }))});\n` + + 'else { process.stderr.write(`unexpected gh ${argv.join(" ")}`); process.exit(1); }\n', { mode: 0o755 }); + git(['init', '-q', '-b', 'dev']); + write('README.md', 'base\n'); + const base = commitAt('08:00', 'Base'); + write('docs/reviews/SPEC-REVIEW-729-r1.md', reviewDoc(1)); + const withDoc = commitAt('10:30', 'docs: review document for #729'); + + // The rebased draft: the document below it in the range head. + write('src/a.ts', 'export const a = 1;\n'); + commitAt('10:10', draftMessage(BH)); + const accepted = runGate(`${base}..HEAD`); + assert.equal(accepted.status, 0, accepted.stdout + accepted.stderr); + assert.doesNotMatch(accepted.stdout, /п\.10/); + + git(['reset', '-q', '--hard', withDoc]); + write('src/a.ts', 'export const a = 2;\n'); + commitAt('10:10', draftMessage(BH_OTHER)); + const stale = runGate(`${base}..HEAD`); + assert.equal(stale.status, 1, stale.stdout + stale.stderr); + assert.match(stale.stdout, /FAIL п\.10/); + assert.ok(stale.stdout.includes(BH_OTHER.slice(0, 12)), stale.stdout); + + // The document only on origin/dev, the draft straight on the base. + git(['update-ref', 'refs/remotes/origin/dev', withDoc]); + git(['reset', '-q', '--hard', base]); + write('src/a.ts', 'export const a = 3;\n'); + commitAt('10:10', draftMessage(BH)); + const fromDev = runGate(`${base}..HEAD`); + assert.equal(fromDev.status, 0, fromDev.stdout + fromDev.stderr); + + // On neither ref. + git(['update-ref', '-d', 'refs/remotes/origin/dev']); + const unproven = runGate(`${base}..HEAD`); + assert.equal(unproven.status, 1, unproven.stdout + unproven.stderr); + assert.match(unproven.stdout, /FAIL п\.10/); + assert.match(unproven.stdout, /git fetch origin dev/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index ffe802e3..1f73a85d 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -9,7 +9,7 @@ import { branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor, trackFromLabels, hasTrackLabel, readMergeState, } from '../scripts/task-packet.mjs'; -import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs'; +import { issueBodyDigest, materialAnchorBlock } from '../scripts/review-doc-guard.mjs'; // #496: пакет задачи — производное представление; проверяется, что он выводится // из меток/комментариев/документов детерминированно и ничего не додумывает. @@ -402,3 +402,78 @@ test('#707 AC11: changelog и визуальное свидетельство', assert.match(visual, /дефект растра или резкости требует свидетеля, красного на старом коде, и подтверждения владельца в GPU-браузере \(§7\.1\)/); assert.doesNotMatch(renderPacket(packetOf({ branch: branchWith({ diff: STYLE }) })), /## Changelog/, 'нет коммитов и нет визуала — раздел не печатается'); }); + +// ---------- #729: черновик во время ревью ТЗ (PROCESS.md §11.8) ---------- + +const DRAFT_BODY = '## ТЗ\n\n- AC1: черновик\n- AC2: пакет\n'; +const draftPacket = ({ labels = ['S4-spec-review', 'track:ask'], body = DRAFT_BODY, specDocs, reviewDocs = [] } = {}) => buildPacket({ + issue: { number: 729, title: 'draft', state: 'OPEN', url: 'u', body }, labels, owner: 'Matysh', branch: null, + reviewDocs, ...(specDocs ? { specDocs } : {}), +}); +const greenSpec = (round, body = DRAFT_BODY, verdict = 'green', high = 0) => ({ + name: `SPEC-REVIEW-729-r${round}.md`, + text: `# SPEC-REVIEW-729-r${round}\nAC1 — разобран\n\n${materialAnchorBlock({ verdict, high, issueBody: issueBodyDigest(body) })}`, +}); + +test('#729 AC7: пакет хеширует тело той же функцией, что якорь конвейера и гейт', () => { + assert.equal(packet.issueBodyDigest, issueBodyDigest); +}); + +test('#729 AC8: S4 на ask — черновик локально можно, в ветку нельзя; трейлер в разделе «Черновик»', () => { + const p = draftPacket(); + assert.ok(p.rights.includes('продуктовый код в ветку — НЕЛЬЗЯ до `S5` (правило №1)'), p.rights.join('\n')); + assert.ok(p.rights.some((l) => l.startsWith('черновик локально — МОЖНО (§11.8)')), p.rights.join('\n')); + assert.ok(p.rights.some((l) => l.startsWith('идёт ревью ТЗ: ждать вердикт')), 'строка ревью ТЗ остаётся'); + assert.ok(p.rights.every((l) => !l.includes('продуктовый код трогать НЕЛЬЗЯ'))); + const md = renderPacket(p); + assert.match(md, /## Черновик \(#729\)/); + assert.ok(md.includes(`Spec-Draft: sha256:${issueBodyDigest(DRAFT_BODY)}`), md); + assert.match(md, /ветку не пушить до `S5`; `S6` — после зелёного ревью ТЗ; комментарий «Черновик:»/); + assert.match(md, /жёлтый или красный вердикт — черновик остановить/); + const json = JSON.parse(JSON.stringify(p)); + assert.deepEqual(Object.keys(json.specDraft).sort(), ['allowed', 'bodyChanged', 'bodyNow', 'green', 'reason', 'trailer']); + assert.equal(json.specDraft.allowed, true); + assert.equal(json.specDraft.trailer, `Spec-Draft: sha256:${issueBodyDigest(DRAFT_BODY)}`); + assert.equal(json.specDraft.bodyNow, issueBodyDigest(DRAFT_BODY)); + + // show and blocked: no «МОЖНО»; blocked says the draft is not kept. + for (const labels of [['S4-spec-review', 'track:show'], ['S4-spec-review', 'track:ask', 'blocked'], ['S4-spec-review', 'review-4']]) { + const other = draftPacket({ labels }); + assert.ok(other.rights.every((l) => !l.includes('МОЖНО')), labels.join(',')); + assert.ok(other.rights.includes('продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)'), labels.join(',')); + assert.equal(other.specDraft.allowed, false, labels.join(',')); + assert.equal(other.specDraft.trailer, null, labels.join(',')); + assert.doesNotMatch(renderPacket(other), /Spec-Draft: sha256:/, labels.join(',')); + } + assert.ok(draftPacket({ labels: ['S4-spec-review', 'track:ask', 'blocked'] }).rights.includes('черновик не ведётся (§11.8): blocked')); + assert.ok(draftPacket({ labels: ['S4-spec-review', 'review-4'] }).rights.includes('черновик не ведётся (§11.8): review-4')); + assert.ok(draftPacket({ labels: ['S4-spec-review', 'track:show'] }).rights.every((l) => !l.includes('черновик')), + 'other tracks print S4 as before'); +}); + +test('#729 AC8: S5/S6 на ask — зелёное ревью ТЗ, хеш, совпадение тела и команда проверки', () => { + const specDocs = [greenSpec(1, 'старый текст', 'yellow'), greenSpec(2), greenSpec(3, DRAFT_BODY, 'green', 1)]; + for (const status of ['S5-ready', 'S6-in-progress']) { + const p = draftPacket({ labels: [status, 'track:ask'], specDocs }); + assert.deepEqual(p.specDraft.green, { doc: 'SPEC-REVIEW-729-r2.md', round: 2, body: issueBodyDigest(DRAFT_BODY) }); + assert.equal(p.specDraft.bodyChanged, false); + const md = renderPacket(p); + assert.ok(md.includes(`зелёное ревью ТЗ: \`SPEC-REVIEW-729-r2.md\` · тело \`sha256:${issueBodyDigest(DRAFT_BODY).slice(0, 12)}\``), md); + assert.match(md, /тело issue сейчас: совпадает/); + assert.match(md, /`node scripts\/process-gate\.mjs --range origin\/dev\.\.HEAD --issues --report`/); + } + const edited = draftPacket({ labels: ['S6-in-progress', 'track:ask'], specDocs, body: `${DRAFT_BODY}- AC3: дописано\n` }); + assert.equal(edited.specDraft.bodyChanged, true); + assert.match(renderPacket(edited), /изменилось после зелёного ревью ТЗ \(ревьюер кода получит находку, #517\)/); + // No green spec review — no section. + assert.doesNotMatch(renderPacket(draftPacket({ labels: ['S5-ready', 'track:ask'], specDocs: [greenSpec(1, DRAFT_BODY, 'yellow')] })), /Черновик \(#729\)/); + + // specDocs do not move the previous verdict nor the AC witness. + const reviewDocs = [{ name: 'CODE-REVIEW-729-r1.md', text: `AC1 — доказан тестом\n\n${materialAnchorBlock({ tree: TREE, verdict: 'yellow', high: 0 })}` }]; + const without = draftPacket({ labels: ['S6-in-progress', 'track:ask'], reviewDocs }); + const withDocs = draftPacket({ labels: ['S6-in-progress', 'track:ask'], reviewDocs, specDocs }); + assert.deepEqual(withDocs.verdict, without.verdict); + assert.deepEqual(withDocs.acceptance, without.acceptance); + assert.deepEqual(withDocs.unverified, without.unverified); + assert.deepEqual(withDocs.productFlow, without.productFlow); +});