diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml
index fb402e5a..e3a04614 100644
--- a/.github/workflows/_process.yml
+++ b/.github/workflows/_process.yml
@@ -1029,8 +1029,8 @@ jobs:
contents: read
issues: write
needs: [guard, prepare]
- # #696: `ship` в рамках сливается без модели — пакетное ревью диапазона
- # перед бетой (PROCESS.md §11.7, ship-review.yml).
+ # #696/#727: `ship` в рамках сливается без модели — пакетное ревью ночью;
+ # перед бетой проверка покрытия и непрочитанная дельта (PROCESS.md §11.7).
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
runs-on: ubuntu-24.04
concurrency:
diff --git a/.github/workflows/_ship-review.yml b/.github/workflows/_ship-review.yml
index 7369663a..caf636b2 100644
--- a/.github/workflows/_ship-review.yml
+++ b/.github/workflows/_ship-review.yml
@@ -375,7 +375,11 @@ jobs:
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
- echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
+ if [ "$MODE" = "nightly" ]; then
+ echo "### Ночное пакетное ревью ship" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "### Предрелизное пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
+ fi
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
exit 0
diff --git a/PROCESS.md b/PROCESS.md
index 3929d890..39d736bf 100644
--- a/PROCESS.md
+++ b/PROCESS.md
@@ -608,14 +608,15 @@ patch-id кандидата слияния: вердикт к работе за
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
-| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
+| Код-ревью | до слияния нет; пакетное ревью ночью, перед бетой — проверка покрытия и непрочитанная дельта (§11.7) | модель: корректность и AC | документ ревью, как в §2.7 |
| Лимит циклов код-ревью | — | 2 | 4 |
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
-диапазона перед бетой (§11.7).
+ночью заранее; перед бетой проверяется покрытие и читается непрочитанная дельта
+(§11.7).
**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы,
но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк
@@ -675,7 +676,8 @@ patch-id кандидата слияния: вердикт к работе за
`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит
`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием:
критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ
-пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не
+пишется в теле issue; до `S5` запрещён push всей ветки, включая тесты и документы
+(§11.8), а не только кода класса A; бюджет код-ревью не
обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача
получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до
`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ —
@@ -1411,7 +1413,7 @@ limit`) и своей логики трека не держит; ответ comp
|---|---|---|---|
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | лёгкий, как у `ship` (#709) |
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
-| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
+| Ревью модели | нет — пакетное ревью ночью; перед бетой проверка покрытия и непрочитанная дельта (§11.7) | корректность и AC; окружение — по нужде | полное |
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | как у `ship` (#709) |
Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship`
@@ -1715,7 +1717,8 @@ Golden, браузерные смоки, performance и полный HA-харн
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
-на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
+на пакетное ревью ночью и проверку покрытия с чтением непрочитанной дельты перед
+бетой (§11.7). Здесь же исключение относится только к
окну между `S8-merged` и выпуском.
### 11.5 Независимое ревью линии перед стабильным релизом
@@ -1784,7 +1787,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
выпуск после такого отказа, исправляют причину и получают новый совместимый
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
-### 11.7 Пакетное ревью `ship` перед бетой
+
+
+### 11.7 Пакетное ревью `ship`: ночью и перед бетой
Решение владельца 2026-09-28, issue #696.
@@ -1793,7 +1798,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
-**Шаг.** Перед публикацией беты — `ship-review.yml`
+**Шаг.** Ночью код читается заранее (режим ниже). Перед публикацией беты
+проверяется покрытие; непрочитанные или изменённые после ревью задачи читает
+`ship-review.yml`
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка
«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`:
без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при
diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md
index c48a1316..cc04d0c6 100644
--- a/docs/process/AUTHOR.md
+++ b/docs/process/AUTHOR.md
@@ -64,8 +64,9 @@
([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
- Вердикт код-ревью `show` с `route: reclassify` конвейер исполняет сам:
`track:ask`, `S3-spec` и комментарий с критерием §5 (#726). Дальше — полное ТЗ
- по §7.1 в теле issue под `## ТЗ` и ревью ТЗ; код остаётся в ветке, код класса A
- не пушить до `S5`. Бюджет код-ревью не обнуляется, лимит — 4. Подтверждённый
+ по §7.1 в теле issue под `## ТЗ` и ревью ТЗ; код остаётся в ветке, до `S5`
+ запрещён push всей ветки, включая тесты и документы. Бюджет код-ревью не
+ обнуляется, лимит — 4. Подтверждённый
владельцем `show` получает `blocked` и вопрос владельцу
([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
- `track:show`: `S2-analysis` → `S5-ready`, до трёх AC автор пишет в теле
@@ -248,7 +249,7 @@
переводит в `track:show`. Код `ship` читает пакетное ревью: ночью
(`SHIP-REVIEW-<база>-dev-.md`) и перед бетой — то, что ночь не прочла
(`SHIP-REVIEW-<тег>.md`) ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер),
- [§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
+ [§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой)).
- Автор обязан дождаться вердикта, а не заканчивать сессию:
`node scripts/wait-verdict.mjs --issue NN`, смотреть на метку, а не на
комментарий; при `blocked` не ждать. После прогона ревью метка меняется
diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md
index be82e770..f611232f 100644
--- a/docs/process/REVIEWER.md
+++ b/docs/process/REVIEWER.md
@@ -182,21 +182,21 @@
- Задачи `track:ship` слиты без ревью модели; их код одной сессией читает
`ship-review.yml` — ночью (`SHIP-REVIEW-<база>-dev-.md`) и перед бетой
то, что ночь не прочла (`SHIP-REVIEW-<тег>.md`): по строке ТЗ каждой задачи и
- её коммитам ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
+ её коммитам ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой)).
- Вопросы к задаче: делает ли код заявленное и только его, не ломает ли
соседнее, не вышла ли правка из ship по смыслу
- ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой),
+ ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой),
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
- Строка «Риск по участкам» под задачей — рискованные участки ship,
подтверждённого владельцем: конвейер их не повышал, пакетное ревью читает их
- первыми ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
+ первыми ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой)).
- Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный
шаг; High не пускает бету, Medium и Low решает владелец
- ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
+ ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой)).
- Ночной документ `docs/reviews/SHIP-REVIEW-<база>-dev-.md` читает только
непокрытые задачи — без документа или изменившиеся после ревью; бета читает
только то, что ночь не прочла: гейт беты принимает ночной документ при том же
- патч-наборе ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
+ патч-наборе ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-ночью-и-перед-бетой)).
## Независимое ревью линии
diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md
index 79454956..c5e750c7 100644
--- a/docs/testing-notes/mutation-browser-guards.md
+++ b/docs/testing-notes/mutation-browser-guards.md
@@ -6,6 +6,11 @@ The guideline is `200` (#699: a guideline, not a wall); `mutation-gate --check`,
must be added deliberately under one reason below, and its mutant `because` must explain
the concrete browser-only invariant.
+`test/mutation-gate.test.mjs` checks each category count against its listed IDs,
+the total against the browser guards in `MUTANTS`, and both guideline numbers
+against `BROWSER_GUARD_LIMIT` (#767). Category names must agree between the table
+and inventory; each current browser guard ID must occur exactly once.
+
Converted witnesses are not listed here: their registry guard names an explicit `node --test`
suite. The original #659 conversions also use `test/mutation-browser-offload.test.mjs`;
new behavioral witnesses execute the consumer directly where possible. Catching the mutant
diff --git a/scripts/reviews-index.mjs b/scripts/reviews-index.mjs
index 3bb46fec..81665392 100644
--- a/scripts/reviews-index.mjs
+++ b/scripts/reviews-index.mjs
@@ -28,8 +28,9 @@ import { isMainModule } from './spawn-portable.mjs';
export const INDEX_FILE = 'INDEX.md';
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
-// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
-// #727: ночной документ `SHIP-REVIEW-<база>-dev-.md` — тег здесь база диапазона.
+// #696/#727: пакетное ревью ship — ночью заранее; перед бетой проверка покрытия
+// и ревью непрочитанной дельты (PROCESS.md §11.7). Документ дельты — по тегу беты;
+// ночной `SHIP-REVIEW-<база>-dev-.md` — по базе диапазона и SHA головы dev.
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)(-dev-[0-9a-f]{12})?\.md$/i;
const COLOUR = {
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
diff --git a/scripts/ship-review.mjs b/scripts/ship-review.mjs
index 0c8d22d1..9770babd 100644
--- a/scripts/ship-review.mjs
+++ b/scripts/ship-review.mjs
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
- * Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
+ * Пакетное ревью `track:ship`: ночью и непрочитанная дельта перед бетой
+ * (#696/#727, PROCESS.md §11.7).
*
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
* node scripts/ship-review.mjs doc --tag=nightly --candidate=
@@ -11,8 +12,9 @@
*
* `ship` сливается без ревью модели (§5): правка в механических рамках и
* зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она
- * уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона
- * «прошлый тег..кандидат беты» одной сессией модели.
+ * уйдёт пользователям: ночью — непокрытые ship-задачи диапазона
+ * «прошлый тег..голова dev», перед бетой — проверка покрытия и ревью
+ * непрочитанных либо изменённых задач «прошлый тег..кандидат беты».
*
* Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели
* оставляет в issue машинный маркер ``.
diff --git a/scripts/wait-verdict.mjs b/scripts/wait-verdict.mjs
index 73f1cb37..30c54d75 100644
--- a/scripts/wait-verdict.mjs
+++ b/scripts/wait-verdict.mjs
@@ -39,7 +39,7 @@ export const PIPELINE_EVENTS = [
{ re: /^Лимит циклов ревью исчерпан/m, kind: 'exhausted', text: 'конвейер: лимит циклов исчерпан — решение владельца' },
// #726: маршрут вердикта show. После исчерпания: тот же комментарий может нести
// и маршрут, но решение тогда за владельцем — первым находится `exhausted`.
- { re: /^\*\*Ревью show: решать есть что — трек повышен до `track:ask`\.\*\*/m, kind: 'reclassify', text: 'конвейер: трек повышен до ask: полное ТЗ в теле issue, код класса A не пушить до S5' },
+ { re: /^\*\*Ревью show: решать есть что — трек повышен до `track:ask`\.\*\*/m, kind: 'reclassify', text: 'конвейер: трек повышен до ask: полное ТЗ в теле issue, всю ветку (включая тесты и документы) не пушить до S5' },
{ re: /^\*\*Ревью show: решать есть что — вопрос владельцу\.\*\*/m, kind: 'owner-question', text: 'конвейер: вопрос владельцу о треке show — ждёт владельца' },
{ re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' },
];
diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs
index 184fc2b8..371df305 100644
--- a/test/mutation-gate.test.mjs
+++ b/test/mutation-gate.test.mjs
@@ -206,17 +206,121 @@ test('#332: каждый гвард реестра классифицирует
assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`);
});
-test('#659/#699: browser guard inventory is reviewed and exact; the limit is a guideline', () => {
+// The registry decides which IDs need a browser; the document decides their
+// reviewed categories. Parse both representations before comparing their counts
+// so a Set cannot hide duplicate IDs or an overwritten category row (#767).
+function assertBrowserGuardInventory(markdown, mutants, guideline) {
+ const lines = markdown.split(/\r?\n/);
+ const tableStart = lines.findIndex((line) => /^\| Category \| Count \|/.test(line));
+ assert.ok(tableStart >= 0, 'browser inventory category table is missing');
+ const counts = new Map();
+ let total;
+ let documentedGuideline;
+ for (const line of lines.slice(tableStart + 2)) {
+ if (!line.startsWith('|')) break;
+ const [, categoryCell, countCell] = line.split('|');
+ const category = categoryCell.replaceAll('**', '').trim();
+ const count = countCell.replaceAll('**', '').trim();
+ if (category === 'Total') {
+ assert.equal(total, undefined, 'duplicate browser inventory total');
+ assert.match(count, /^\d+\s*\/\s*\d+$/, 'browser inventory total / guideline must be numeric');
+ [total, documentedGuideline] = count.split('/').map(Number);
+ } else {
+ assert.ok(!counts.has(category), `duplicate category table row: ${category}`);
+ assert.match(count, /^\d+$/, `category count must be numeric: ${category}`);
+ counts.set(category, Number(count));
+ }
+ }
+
+ const inventoryStart = lines.indexOf('## Reviewed per-mutant inventory');
+ assert.ok(inventoryStart >= 0, 'reviewed per-mutant inventory is missing');
+ const categories = new Map();
+ const documented = new Set();
+ let currentCategory;
+ for (const line of lines.slice(inventoryStart + 1)) {
+ if (line.startsWith('## ')) break;
+ const heading = /^### (.+)$/.exec(line);
+ if (heading) {
+ currentCategory = heading[1].trim();
+ assert.ok(!categories.has(currentCategory), `duplicate inventory category: ${currentCategory}`);
+ categories.set(currentCategory, []);
+ }
+ const item = /^- `([^`]+)`\s*$/.exec(line);
+ if (!item) continue;
+ assert.ok(currentCategory, `browser guard has no category: ${item[1]}`);
+ assert.ok(!documented.has(item[1]), `duplicate browser guard ID: ${item[1]}`);
+ documented.add(item[1]);
+ categories.get(currentCategory).push(item[1]);
+ }
+ assert.ok(categories.size > 0, 'browser inventory has no categories');
+ assert.deepEqual([...counts.keys()].sort(), [...categories.keys()].sort(),
+ 'category table and reviewed inventory must name the same categories');
+ const policy = browserGuardPolicy(mutants, documented);
+ assert.deepEqual(policy.missingReasons, [], 'browser registry IDs missing from inventory');
+ assert.deepEqual(policy.staleReasons, [], 'inventory IDs absent from browser registry');
+ for (const [category, ids] of categories) {
+ assert.equal(counts.get(category), ids.length, `category count differs from listed IDs: ${category}`);
+ }
+ assert.equal(total, policy.count, 'browser inventory total differs from registry');
+ assert.equal(total, [...counts.values()].reduce((sum, count) => sum + count, 0),
+ 'browser inventory total differs from category sum');
+ assert.equal(documentedGuideline, guideline, 'browser inventory guideline differs from policy');
+ const proseGuideline = /The guideline is `(\d+)`/.exec(markdown);
+ assert.ok(proseGuideline, 'browser inventory prose guideline is missing');
+ assert.equal(Number(proseGuideline[1]), guideline, 'browser inventory prose guideline differs from policy');
+}
+
+test('#659/#699/#767: browser guard inventory IDs, category counts and total match the registry', () => {
const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8');
+ assertBrowserGuardInventory(markdown, MUTANTS, BROWSER_GUARD_LIMIT);
const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown));
assert.ok(policy.count > 0);
assert.deepEqual(policy.missingReasons, []);
assert.deepEqual(policy.staleReasons, []);
- assert.equal(BROWSER_GUARD_LIMIT, 200, 'ориентир остаётся числом в выводе --check');
const over = browserGuardPolicy([...MUTANTS, ...Array.from({ length: BROWSER_GUARD_LIMIT + 1 }, (_, i) => ({ id: `x-${i}`, guard: 'node demo/smoke_x.mjs' }))], new Set());
assert.equal(over.overLimit, true, 'сверх ориентира policy это видит — --check предупреждает');
});
+test('#767: browser inventory count contract rejects numeric and membership drift', () => {
+ const mutants = ['alpha', 'beta', 'gamma'].map((id) => ({ id, guard: 'node demo/smoke_x.mjs' }));
+ mutants.push({ id: 'unit-only', guard: 'node --test test/x.test.mjs' });
+ const markdown = [
+ 'The guideline is `2`.',
+ '| Category | Count | Why a browser is still required |',
+ '| --- | ---: | --- |',
+ '| Paint | 2 | Pixels |',
+ '| Lifecycle | 1 | Events |',
+ '| **Total** | **3 / 2** | Guideline only |',
+ '',
+ '## Reviewed per-mutant inventory',
+ '### Paint',
+ '- `alpha`',
+ '- `beta`',
+ '### Lifecycle',
+ '- `gamma`',
+ ].join('\n');
+ assert.doesNotThrow(() => assertBrowserGuardInventory(markdown, mutants, 2));
+ assert.doesNotThrow(() => assertBrowserGuardInventory(markdown.replaceAll('\n', '\r\n'), mutants, 2));
+ const cases = [
+ ['wrong category count', markdown.replace('| Paint | 2 |', '| Paint | 1 |'), /category count differs/],
+ ['wrong total', markdown.replace('**3 / 2**', '**4 / 2**'), /total differs from registry/],
+ ['wrong table guideline', markdown.replace('**3 / 2**', '**3 / 4**'), /guideline differs from policy/],
+ ['wrong prose guideline', markdown.replace('guideline is `2`', 'guideline is `4`'), /prose guideline differs/],
+ ['missing total', markdown.replace(/^\| \*\*Total\*\*.*\n/m, ''), /total differs from registry/],
+ ['duplicate total', markdown.replace('| **Total** |', '| **Total** | **3 / 2** | Duplicate |\n| **Total** |'), /duplicate browser inventory total/],
+ ['missing table category', markdown.replace(/^\| Paint.*\n/m, ''), /must name the same categories/],
+ ['missing inventory category', markdown.replace('### Lifecycle\n', ''), /must name the same categories/],
+ ['duplicate table category', markdown.replace('| Paint | 2 | Pixels |', '| Paint | 2 | Pixels |\n| Paint | 2 | Pixels |'), /duplicate category table row/],
+ ['duplicate inventory category', `${markdown}\n### Paint`, /duplicate inventory category/],
+ ['missing ID', markdown.replace('- `alpha`\n', ''), /registry IDs missing from inventory/],
+ ['duplicate ID', `${markdown}\n- \`alpha\``, /duplicate browser guard ID/],
+ ['non-browser ID', `${markdown}\n- \`unit-only\``, /inventory IDs absent from browser registry/],
+ ];
+ for (const [name, changed, message] of cases) {
+ assert.throws(() => assertBrowserGuardInventory(changed, mutants, 2), message, name);
+ }
+});
+
test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => {
const corpus = new Set(['src/card.ts', 'scripts/source-fingerprint.mjs']);
const smoke = (file) => ({ guard: 'node demo/smoke_x.mjs', patches: [{ file }] });
diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs
index 4ab30ee4..c4b2703d 100644
--- a/test/process-digests.test.mjs
+++ b/test/process-digests.test.mjs
@@ -52,6 +52,7 @@ const KEY_RULES = {
['5-треки-ship-show-ask--метка-владельца', 'строкой `Трек: — решение владельца` в начале строки'],
['5-треки-ship-show-ask--метка-владельца', 'Метка без такой строки — предложение'],
['5-треки-ship-show-ask--метка-владельца', 'Агент эту строку не пишет никогда'],
+ ['5-треки-ship-show-ask--метка-владельца', 'до `S5` запрещён push всей ветки, включая тесты и документы'],
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #` и `User-Visible: yes|no`'],
@@ -109,7 +110,7 @@ const KEY_RULES = {
['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'],
['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'],
// #727: ночное пакетное ревью ship переиспользуется гейтом беты по патч-набору.
- ['117-пакетное-ревью-ship-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'],
+ ['117-пакетное-ревью-ship-ночью-и-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'],
// #729: готовый черновик не довод за непринятое ТЗ.
['24-тз-на-ревью', 'ревьюер ТЗ не читает и доводом не считает'],
],
diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs
index 40235f18..bc8805fa 100644
--- a/test/ship-review.test.mjs
+++ b/test/ship-review.test.mjs
@@ -1,5 +1,5 @@
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
-// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
+// читает пакетное ревью ночью и по дельте перед бетой; гейт беты требует покрытие.
import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
@@ -619,6 +619,8 @@ test('#727 AC2/AC5 _ship-review.yml на настоящем bash: ночь чи
const bogus = { high: 0, medium: 1, low: 0, summary: 'ok', patches: '701:' + 'd'.repeat(40) };
const published = box.publish(first, bogus, { TAG: 'nightly' });
assert.equal(published.status, 0, published.stderr + published.stdout);
+ assert.match(published.summary, /^### Ночное пакетное ревью ship$/m);
+ assert.doesNotMatch(published.summary, /Предрелизное|Пакетное ревью ship nightly/);
const text = box.git(box.work, 'show', `origin/dev:${nightDoc}`);
const block = parseAnchorBlock(text);
assert.equal(block.mode, 'nightly');
@@ -694,6 +696,20 @@ test('#727 AC2/AC5 _ship-review.yml на настоящем bash: ночь чи
assert.match(foreign.stderr, /не предок origin\/dev/);
});
+test('#767: публикация предрелизного ship-ревью называет режим и тег в сводке', (t) => {
+ if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; }
+ const box = shipSandbox(t);
+ box.issue(701);
+ box.commit({ 'a.mjs': 'export const a = 1;\n' }, 'fix: a (#701)\n\nIssue: #701\nUser-Visible: no');
+ box.push();
+ const prepared = box.prepare({ TAG: 'v1.1.0-beta.1' });
+ assert.equal(prepared.status, 0, prepared.stderr);
+ const published = box.publish(prepared, { high: 0, medium: 0, low: 0, summary: 'ok' }, { TAG: 'v1.1.0-beta.1' });
+ assert.equal(published.status, 0, published.stderr + published.stdout);
+ assert.match(published.summary, /^### Предрелизное пакетное ревью ship v1\.1\.0-beta\.1$/m);
+ assert.doesNotMatch(published.summary, /Ночное|nightly/);
+});
+
test('#727 AC8 _ship-review.yml на настоящем bash: строка о High — только ночью и только при High > 0, без повтора', (t) => {
if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; }
const box = shipSandbox(t);
diff --git a/test/wait-verdict.test.mjs b/test/wait-verdict.test.mjs
index 9e985edc..ed2c7570 100644
--- a/test/wait-verdict.test.mjs
+++ b/test/wait-verdict.test.mjs
@@ -135,7 +135,7 @@ test('#726 AC7: reclassify доставляется автору видом recl
const comment = { id: 'r', createdAt: '2026-10-01T10:05:00Z', body };
const state = stateOf(snap(['S3-spec', 'track:ask'], [comment]));
assert.equal(state.lastEvent.kind, 'reclassify');
- assert.match(state.lastEvent.text, /трек повышен до ask: полное ТЗ в теле issue, код класса A не пушить до S5/);
+ assert.equal(state.lastEvent.text, 'конвейер: трек повышен до ask: полное ТЗ в теле issue, всю ветку (включая тесты и документы) не пушить до S5');
// Метка сменилась тем же прогоном — вердикт (0) и текст маршрута в строках.
const moved = decide(stateOf(snap(['S7-code-review', 'track:show'])), state);
assert.equal(moved.code, 0);