mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
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:
@@ -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
@@ -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
@@ -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-правила)).
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
вынесенный владельцу, ревьюер снимает и решает по существу. Технический
|
||||
спор автора и ревьюера решается вердиктом, а не владельцем
|
||||
([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- Черновик автора ревьюер ТЗ не читает и доводом не считает: судится тело
|
||||
issue ([§2.4](../../PROCESS.md#24-тз-на-ревью),
|
||||
[§11.8](../../PROCESS.md#118-черновик-кода-во-время-ревью-тз)).
|
||||
|
||||
## Код-ревью
|
||||
|
||||
|
||||
+189
-5
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user