infra: idempotent review controller, verdict reuse, single mutant build, current docs

Review pipeline (process.yml):
- concurrency moves from the workflow to the guard/review jobs and the guard
  runs only for S4-spec-review / S7-code-review. Any other label used to enter
  the issue's concurrency group and evict the pending review run (sample of
  150 runs since 2026-09-01: 92 empty guard-only runs, 30 cancelled).
- the guard reads the issue's current labels instead of the event snapshot; a
  label removed before the run starts is a withdrawn request, no comment.
- a green verdict is re-applied without calling the model when the latest
  review document carries the pipeline-recorded verdict `green`/High 0 and the
  tree differs from its anchor in nothing outside docs/reviews/** (#437 r4
  re-reviewed an unchanged tree for 7 minutes). The verdict from
  structured_output is now written into the anchor block for that purpose.
- the reviewer is pinned to the captured material SHA in the prompt; the
  broken escaping in the "merge cancelled" comment (empty SHAs) is fixed.

Mutation gate: nine browser guards started with `npm run bundle:sync` although
the runner already builds the mutant bundle — a second rollup plus a
`tsc --noEmit` that fails on a non-strict mutant before the smoke even runs.
Prefix removed; `--check` refuses guards that build the bundle themselves.

Docs: SCOPE (Project v2 dropped, three editors), STATUS (#437 merged, HACS zip
automated), USER-GUIDE ru/en (static card shows live states; kiosk double tap
on free background fits all), #34 → #425 references, #367 named as closed in
bundle-budget messages, PROCESS §10.4 and AGENTS.md describe the controller.

Issue: #499
User-Visible: no
This commit is contained in:
Claude
2026-09-09 00:06:27 +03:00
parent ea6061e9fa
commit 24c1b723f9
14 changed files with 344 additions and 48 deletions
+5 -4
View File
@@ -259,7 +259,7 @@ export function initialViewCeilingViolation(bytes, {
over: bytes - ceiling,
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
+ ' граф (#367). Молча расти этому графу больше нечем.',
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
};
}
if (bytes < ceiling - band) {
@@ -268,7 +268,7 @@ export function initialViewCeilingViolation(bytes, {
under: ceiling - bytes,
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
+ ' отыграет обратно, и это уже происходило (#367).',
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
};
}
return null;
@@ -290,10 +290,11 @@ export function lowHeadroomWarning(headroom, {
? ` Признание долга покрывает потолок до ${acknowledgedCeiling} Б, а он уже ${ceiling} Б —`
+ ' граф вырос с тех пор, вопрос вернулся.'
: ' Погасить можно решением владельца: LOW_HEADROOM_ACKNOWLEDGED_CEILING в'
+ ' scripts/bundle-budget.mjs. Пока не погашено — это долг (#367).';
+ ' scripts/bundle-budget.mjs. Живого issue у долга нет: #367 закрыт рекалибровкой,'
+ ' его рычаг сделан в #474 (#499) — следующий рост заводит новый.';
return `запас бюджета ${headroom} Б, меньше порога ${threshold} Б:`
+ ' следующая средняя фича упрётся в стену. Рекалибровка это не лечит —'
+ ' смотрите ленивые графы (#367).' + stale;
+ ' смотрите ленивые графы (история: #367 → #474).' + stale;
}
export function assertBundleBudget(
+19 -9
View File
@@ -1359,7 +1359,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'vacuum-overlay-back-to-the-dock-space-filter',
guard: 'npm run bundle:sync && node demo/smoke_vacuum_multifloor.mjs',
guard: 'node demo/smoke_vacuum_multifloor.mjs',
because: 'the overlay layer must see every robot of the plan, not only those whose DOCK '
+ 'is in the space on screen: the old filter is exactly why a multi-floor robot could '
+ 'never appear on its second floor (#162, AC2)',
@@ -1383,7 +1383,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'vacuum-manual-fit-after-proposal-uses-the-dock',
guard: 'npm run bundle:sync && node demo/smoke_vacuum_multifloor.mjs',
guard: 'node demo/smoke_vacuum_multifloor.mjs',
because: 'the high-residual proposal is refined against the geometry the matrix was solved '
+ 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, '
+ 'the stateful half the pure calibrationTarget mutant cannot reach)',
@@ -4297,7 +4297,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'near-axis-authoring-snap-bypassed',
guard: 'npm run bundle:sync && node demo/smoke_plan_drawing_repairs.mjs',
guard: 'node demo/smoke_plan_drawing_repairs.mjs',
because: 'the production Walls hover and click must persist the exact same straight endpoint '
+ 'instead of merely repairing old data through Optimize (#290)',
patches: [{
@@ -4331,7 +4331,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'near-axis-optimize-confirmation-bypassed',
guard: 'npm run bundle:sync && node demo/smoke_near_axis_optimize.mjs',
guard: 'node demo/smoke_near_axis_optimize.mjs',
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
@@ -6242,7 +6242,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'current-rejected-physical-write-keeps-optimistic-wall',
guard: 'npm run bundle:sync && node demo/smoke_v8_draft_write.mjs',
guard: 'node demo/smoke_v8_draft_write.mjs',
because: 'a rejected config/set must synchronously discard its whole pending physical batch '
+ 'before an optimistic active-chain partition survives as a ghost wall (#314/#478)',
patches: [{
@@ -6253,7 +6253,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'area-relocation-loses-position-on-refusal',
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
guard: 'node demo/smoke_area_relocation_safety.mjs',
because: 'a rejected provenance write must restore every manual layout point deleted '
+ 'before config/set, or leave explicit attention when restoration also fails (#403)',
patches: [{
@@ -6264,7 +6264,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'area-relocation-clears-whole-history',
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
guard: 'node demo/smoke_area_relocation_safety.mjs',
because: 'moving one marker to a new HA Area must invalidate only that marker\'s commands '
+ 'instead of silently erasing Undo and Redo for every other marker (#403)',
patches: [{
@@ -7305,7 +7305,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'furniture-art-editor-adopt-skipped',
guard: 'npm run bundle:sync && node demo/smoke_furniture.mjs',
guard: 'node demo/smoke_furniture.mjs',
because: 'the editor imports the artwork statically and must hand it over synchronously; without '
+ 'adopt the palette previews and the placement ghost render empty on a plan without furniture (#474 r1)',
patches: [{
@@ -7567,7 +7567,7 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'writer-history-skips-finished-chain-normalization',
guard: 'npm run bundle:sync && node demo/smoke_writer_fixed_point.mjs',
guard: 'node demo/smoke_writer_fixed_point.mjs',
because: 'Undo/Redo after a chain has finished must restore canonical snapshots rather than '
+ 'bringing the hidden collinear seams back into durable config (#477)',
patches: [{
@@ -7737,6 +7737,9 @@ export function guardNeedsTestBuild(guard) {
* выполняется только там, где её результат кто-то откроет.
*/
export function guardNeedsBundle(guard) {
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
// браузерный, и бандл ему нужен.
return guard.includes('demo/') || guard.includes('bundle:sync');
}
@@ -8015,6 +8018,13 @@ function main(argv) {
let stale = 0;
for (const m of selected) {
try {
// #499: бандл мутанта собирает раннер (`buildBundle`, только rollup +
// sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй
// раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и
// красит гвард ещё до теста: «мутант пойман» без единого запуска смока.
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) {
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
}
for (const patch of m.patches) {
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
const hits = source.split(patch.find).length - 1;
+1 -1
View File
@@ -9,7 +9,7 @@
* Зачем гейт, а не разовая типизация. В `src/**` сейчас 1034 вхождения явного
* `any` в 49 файлах — перетипизировать это одним заходом значит месяц риска ради
* нуля пользовательской ценности. Долг снимается при плановом извлечении
* подсистем (#34). Задача гейта одна: не давать долгу расти.
* подсистем (#425, прежний #34). Задача гейта одна: не давать долгу расти.
*
* Практический вред уже случался: несоответствие форм (`d.source.kind` против
* строкового `source`) компилятор не поймал, потому что путь был через `any`, и
+97 -1
View File
@@ -252,7 +252,7 @@ export function anchorLiveness(object, run) {
return false;
}
export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high } = {}) {
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
const lines = [
ANCHOR_MARKER,
@@ -277,6 +277,13 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
if (!tree && !specs.length) {
lines.push('- Якоря снять не удалось: ветки задачи нет, материал читался по `dev`.');
}
// Вердикт из structured_output модели, записанный конвейером (#499): по нему
// следующий заход решает, можно ли применить зелёный вердикт повторно без
// вызова модели. Прозу документа для этого читать нельзя — она цитирует
// прошлые раунды и пишется в свободной форме.
if (verdict) {
lines.push(`- Вердикт конвейера: \`${verdict}\` · High ${Number.isFinite(Number(high)) ? Number(high) : '?'}`);
}
return `${lines.join('\n')}\n`;
}
@@ -430,6 +437,63 @@ export function isBlockingVerdict(line) {
return Boolean(line) && BLOCKING_COLOUR.test(line);
}
/** Дерево материала из блока якорей документа (#414), либо `null`. */
export function anchorTreeFrom(text) {
const body = String(text ?? '');
const at = body.indexOf(ANCHOR_MARKER);
if (at < 0) return null;
const match = body.slice(at).match(/Дерево материала: `([0-9a-f]{40})`/);
return match ? match[1] : null;
}
/** Вердикт конвейера из блока якорей (#499): `{ verdict, high }` либо `null`. */
export function anchorVerdictFrom(text) {
const body = String(text ?? '');
const at = body.indexOf(ANCHOR_MARKER);
if (at < 0) return null;
const match = body.slice(at).match(/Вердикт конвейера: `(green|yellow|red)` · High (\d+)/);
return match ? { verdict: match[1], high: Number(match[2]) } : null;
}
/**
* Повторное применение зелёного вердикта без вызова модели (#499).
*
* Сценарий #437 r4: зелёный r3 не слился (страж #312 не признал вершину), задача
* вернулась в S6 и тут же в S7 — и ревьюер двенадцать минут заново разбирал
* дерево, в котором с r3 изменился ровно один файл: собственный документ r3.
*
* Правило узкое и одностороннее: вердикт переиспользуется ТОЛЬКО если
* - последний опубликованный документ этапа несёт записанный КОНВЕЙЕРОМ
* вердикт `green` с High 0 (из structured_output модели, не из прозы);
* - его якорь «дерево материала» снят конвейером (#414), а не написан рукой;
* - текущее дерево отличается от якоря НИЧЕМ, кроме docs/reviews/** —
* сравнение делает git по содержимому, так что ребейз на ушедший dev,
* правка теста, фикстуры, скрипта или ТЗ в docs/specs дают отличие и
* полный разбор (§7.2). Смена базы без изменения дерева невозможна:
* дерево ветки после ребейза включает содержимое dev.
* Любое сомнение — `null`, и ревью идёт как обычно.
*
* @param docs [{ name, text }] опубликованные документы этапа этой задачи
* @param differs (tree) => boolean — есть ли отличие дерева от HEAD вне docs/reviews
*/
export function reusableGreenVerdict(docs, differs) {
const numbered = (docs || [])
.map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) }))
.filter((doc) => Number.isFinite(doc.round))
.sort((a, b) => b.round - a.round);
const latest = numbered[0];
if (!latest) return null;
// Судится запись конвейера, не проза: документ r4 по #437 держит вердикт
// словом «Зелёный.» под заголовком раздела и цитирует r3 строкой «Вердикт
// r3: зелёный» — ни то, ни другое не годится как машинный источник.
const recorded = anchorVerdictFrom(latest.text);
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) return null;
const tree = anchorTreeFrom(latest.text);
if (!tree) return null;
if (differs(tree)) return null;
return { doc: latest.name, round: latest.round, tree, verdict: 'green' };
}
/**
* Блокирующие раунды по документам: `{ blocking, unread }`.
*
@@ -607,6 +671,36 @@ if (invokedDirectly) {
process.exit(0);
}
// Режим повторного применения зелёного вердикта (#499): документы этапа
// читаются из рабочей копии (они в дереве ветки), отличие дерева судит git.
// --reuse --marker=CODE-REVIEW --num=437 [--head=HEAD]
// Печатает `reuse=true|false` и, при true, `doc=`, `round=`, `tree=`, `verdict=`.
if (argv.includes('--reuse')) {
const value = (name) => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
return found ? found.slice(name.length + 3) : '';
};
const marker = value('marker'); const num = value('num'); const head = value('head') || 'HEAD';
const listed = spawnSync('git', ['ls-tree', '--name-only', `${head}:docs/reviews`], { encoding: 'utf8' });
const names = (listed.stdout || '').split('\n')
.filter((name) => new RegExp(`^${marker}-${num}-r\\d+\\.md$`).test(name));
const docs = names.map((name) => {
const shown = spawnSync('git', ['show', `${head}:docs/reviews/${name}`], { encoding: 'utf8' });
return { name, text: shown.status === 0 ? shown.stdout : '' };
});
const differs = (tree) => {
// Сначала дерево обязано существовать: неизвестный объект — не «совпало».
const exists = spawnSync('git', ['cat-file', '-e', `${tree}^{tree}`], { encoding: 'utf8' });
if (exists.status !== 0) return true;
const diff = spawnSync('git', ['diff', '--quiet', tree, head, '--', '.', ':!docs/reviews'], { encoding: 'utf8' });
return diff.status !== 0;
};
const found = reusableGreenVerdict(docs, differs);
if (!found) { process.stdout.write('reuse=false\n'); process.exit(0); }
process.stdout.write(`reuse=true\ndoc=${found.doc}\nround=${found.round}\ntree=${found.tree}\n`);
process.exit(0);
}
// Режим дописывания якорей (#414): конвейер снял их при чтении материала.
const anchorArg = argv.find((item) => item.startsWith('--anchor='));
if (anchorArg) {
@@ -620,6 +714,8 @@ if (invokedDirectly) {
tree: value('tree'),
branch: value('branch'),
specs: parseSpecList(value('specs')),
verdict: ['green', 'yellow', 'red'].includes(value('verdict')) ? value('verdict') : '',
high: value('high'),
};
const text = readFileSync(path, 'utf8');
writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8');