feat(process): a track:ask draft may be written during the spec review (#729)

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:<issueBodyDigest(body)>`,
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 11:47:01 +03:00
parent 7ff2b5aeaa
commit 0e44f8a69f
10 changed files with 882 additions and 34 deletions
+6 -2
View File
@@ -63,7 +63,11 @@ gh issue view <NN> --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 |
+86 -8
View File
@@ -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):** назначить себя, поставить метку, комментарий
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
**3** одновременно на цикл релиза, не более **2** в «Код-ревью». Черновик
`track:ask` во время ревью ТЗ (§11.8) занимает тот же слот: у исполнителя
одновременно одна задача в `S6-in-progress` или с черновиком.
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт трейлеры
`Issue: #<NN>` и `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 (причина)`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Черновик** (§11.8): `Черновик: <роль> · сессия <id> · локальная ветка
issue/NN-slug · Spec-Draft sha256:<первые 12>`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
@@ -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/<NN>-<slug>` — той, что будет
запушена после `S5`. Вторых веток `issue/<NN>-*` и параллельных черновиков
нет.
- До `S5` ничего не пушится — ни коммиты класса A, ни сама ветка: на этапе
spec конвейер выбирает ветку `issue/<NN>-*` материалом и кладёт в неё
SPEC-REVIEW. Если ветка уже есть на origin (после `reclassify` #726 или
запушена в `S3`), черновик после `S5` ребейзится на `origin/issue/<NN>-*`,
где лежит 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/<NN>-*`, если ветка была), проверяет
`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;
+38 -4
View File
@@ -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/<NN>-<slug>`; до `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/<NN>-*`, если ветка
уже была; без 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`)
- Занятие: `Взял: <роль> · сессия <id> · ветка 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-правила)).
+3
View File
@@ -36,6 +36,9 @@
вынесенный владельцу, ревьюер снимает и решает по существу. Технический
спор автора и ревьюера решается вердиктом, а не владельцем
([§7.1](../../PROCESS.md#71-цепочка)).
- Черновик автора ревьюер ТЗ не читает и доводом не считает: судится тело
issue ([§2.4](../../PROCESS.md#24-тз-на-ревью),
[§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)).
## Код-ревью
+189 -5
View File
@@ -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 ?? '<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);
+119 -13
View File
@@ -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)) {
+5
View File
@@ -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';
+5
View File
@@ -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-тз-на-ревью', 'ревьюер ТЗ не читает и доводом не считает'],
],
};
+355 -1
View File
@@ -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('/'), '<allowed>') }));
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 });
}
});
+76 -1
View File
@@ -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);
});