From 52a56430abbf98f66233b9637a5f7c78cc8d0600 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:45:44 +0300 Subject: [PATCH] process: an unproven smoke link runs the visual minimum; raster defects need a witness (#690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two remaining owner decisions of #690 and the legacy trivial text. - scripts/smoke-select.mjs: VISUAL_MINIMUM, eight smokes of modes, layers and rendering (under a minute locally). An executable diff with no proven link now returns and prints it instead of only "the reviewer decides"; #687 missed smoke_modes that way (item 1'). - scripts/gate-small.mjs: `--smokes` runs the minimum with the selection. - PROCESS §7.1 and AUTHOR.md: a raster, sharpness or compositing defect needs a witness red on the old code for the owner's symptom and the owner's confirmation in a real GPU browser (item 4). - PROCESS §8, TESTING.md: the minimum in the smoke-select rule. - scripts/task-packet.mjs: legacy `trivial` is product flow read as track:show (§5.1), not a short track without a spec. - Tests; mutants visual-minimum-silent-again, visual-minimum-on-proven-link, gate-small-skips-visual-minimum; task-packet-trivial-is-product-flow retargeted. Issue: #690 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- PROCESS.md | 10 ++++++++- docs/TESTING.md | 5 ++++- docs/process/AUTHOR.md | 7 ++++++- scripts/gate-small.mjs | 12 ++++++++--- scripts/mutation-registry.mjs | 38 +++++++++++++++++++++++++++++++++-- scripts/smoke-select.mjs | 30 +++++++++++++++++++++++++-- scripts/task-packet.mjs | 11 +++++----- test/smoke-select.test.mjs | 30 +++++++++++++++++++++++++-- test/task-packet.test.mjs | 2 +- 9 files changed, 127 insertions(+), 18 deletions(-) diff --git a/PROCESS.md b/PROCESS.md index 373172d6..db83806b 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -690,6 +690,11 @@ issue #NN которое не может ответить на эти два вопроса, описывает работу, а не изменение продукта. +**Дефект растра, резкости или композитинга** — на любом треке (#690): до кода +нужен свидетель, красный на старом коде именно по симптому владельца, а в AC — +подтверждение владельца в реальном GPU-браузере. Headless-доказательства мало: +#685 доказал правку в headless Linux и не устранил симптом, его откатил #689. + **Размытое место не додумывается, а выносится владельцу.** Догадка, записанная как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением. @@ -791,7 +796,10 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ дерева; прогон всех уместен только когда задача задевает всё; какие относятся к диффу, печатает `node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод -прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при +прикладывается к ревью вместе с решением по каждой строке; если связь не +доказана, он печатает визуальный минимум — восемь смоков режимов, слоёв и +отрисовки, локально меньше минуты, — и автор гоняет его до `S7`, `gate:small -- +--smokes` делает это сам, #690), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.** Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml` diff --git a/docs/TESTING.md b/docs/TESTING.md index 102cf5f2..f130c42b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -305,7 +305,10 @@ node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1 Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода — «смоки не требуются»; прямое совпадение или зарегистрированная связь — прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не -«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. +«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. Вместе с ней +печатается визуальный минимум (`VISUAL_MINIMUM` в `scripts/smoke-select.mjs`): +восемь смоков режимов, слоёв и отрисовки, которые `--smokes` гоняет вместе с +выборкой (#690). CSS-правка #687 без него не выбрала `smoke_modes`. ### В хуке — по умолчанию для веток задач (#633) diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index 5c0721c8..b5f65f7f 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -78,6 +78,10 @@ входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и записывает блоком «принято предположительно, поменять свободно». Смешанный вопрос делится ([§7.1](../../PROCESS.md#71-цепочка)). +- Дефект растра, резкости или композитинга: до кода нужен свидетель, красный + на старом коде именно по симптому владельца; в AC — подтверждение владельца + в реальном GPU-браузере. Headless-доказательства мало (урок #685 → #689, + [§7.1](../../PROCESS.md#71-цепочка)). - Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа · вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)). @@ -141,7 +145,8 @@ - Обязательная часть — `npm run gate:small`: его состав живёт в `scripts/gate-small.mjs` и нигде не переписывается. По диффу и AC сверх - него — целевые смоки из вывода `smoke-select`, `model-invariants`, + него — целевые смоки из вывода `smoke-select` (связь не доказана — его + визуальный минимум, `--smokes` гоняет его сам, #690), `model-invariants`, `pytest tests_backend`, junction parity; `golden:verify` — только с меткой `ci:golden` ([§8](../../PROCESS.md#8-гейты); `docs/TESTING.md`, «Локальный набор перед пушем»). diff --git a/scripts/gate-small.mjs b/scripts/gate-small.mjs index d227d21b..6169d7c6 100644 --- a/scripts/gate-small.mjs +++ b/scripts/gate-small.mjs @@ -36,10 +36,13 @@ export function parseArgs(argv) { return { base, smokes, jobs: Number.isInteger(jobs) && jobs > 0 ? jobs : 2 }; } -/** Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без «широких». */ +/** + * Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без + * «широких»; при недоказанной связи — ещё визуальный минимум (#690 п.1′). + */ export function smokesToRun(selection) { if (!selection || selection.noExecutableDiff) return []; - const names = [...(selection.direct || []), ...(selection.registered || [])] + const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])] .map((entry) => (typeof entry === 'string' ? entry : entry.smoke)) .filter(Boolean); return [...new Set(names)].sort(); @@ -144,7 +147,10 @@ export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = fals else if (selection.noExecutableDiff) log('smoke-select: исполняемого frontend-диффа нет — смоки этим диффом не выбираются'); else { const names = smokesToRun(selection); - log(`smoke-select: прямые и зарегистрированные (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`); + const what = (selection.visualMinimum || []).length + ? 'связь не доказана — прямые, зарегистрированные и визуальный минимум (#690)' + : 'прямые и зарегистрированные'; + log(`smoke-select: ${what} (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`); for (const name of names) log(` demo/${name}`); if ((selection.broad || []).length) log(` «широких» символов: ${selection.broad.length} — решает ревьюер, автоматически не гоняются`); } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 9e74bc76..81aa214f 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -3623,11 +3623,11 @@ const MUTANT_DEFINITIONS = [ { id: 'task-packet-trivial-is-product-flow', guard: 'node --test test/task-packet.test.mjs', - because: 'the trivial short track writes no spec and no spec review, so in S6/S7 its label is ' + because: 'a legacy trivial task wrote no spec and no spec review, so in S6/S7 its label is ' + 'the only product-flow evidence; dropping it prints the false class A ban of #632 again (r1)', patches: [{ file: 'scripts/task-packet.mjs', - find: " if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');", + find: " if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');", replace: "", }], }, @@ -6167,6 +6167,40 @@ const MUTANT_DEFINITIONS = [ replace: " status: degradedExtraCount || degradedCoreCount ? 'failed-core' : 'ok',", }], }, + // #690 п.1′: недоказанная связь смоков выдаёт визуальный минимум, а не пустоту. + { + id: 'visual-minimum-silent-again', + guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs', + because: '#690: an executable diff with no proven smoke link printed only "the reviewer decides" ' + + 'and ran nothing; #687 missed smoke_modes that way and left dev red for #685', + patches: [{ + file: 'scripts/smoke-select.mjs', + find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],', + replace: ' visualMinimum: [],', + }], + }, + { + id: 'visual-minimum-on-proven-link', + guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs', + because: '#690: the minimum is for the unproven case only; with a proven link the selection must ' + + 'stay smaller than the matrix', + patches: [{ + file: 'scripts/smoke-select.mjs', + find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],', + replace: ' visualMinimum: parsed.executable.length ? [...VISUAL_MINIMUM] : [],', + }], + }, + { + id: 'gate-small-skips-visual-minimum', + guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs', + because: '#690: gate:small -- --smokes must run the visual minimum it was handed, not only the ' + + 'direct and registered smokes', + patches: [{ + file: 'scripts/gate-small.mjs', + find: ' const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])]', + replace: ' const names = [...(selection.direct || []), ...(selection.registered || [])]', + }], + }, { id: 'wall-isolated-extra-discarded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/scripts/smoke-select.mjs b/scripts/smoke-select.mjs index 6a557884..3f3cf2b8 100644 --- a/scripts/smoke-select.mjs +++ b/scripts/smoke-select.mjs @@ -41,6 +41,26 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); */ export const BROAD_SHARE = 0.2; +/** + * «Визуальный минимум» (#690 п.1′): смоки режимов, слоёв и отрисовки, которые + * выдаются, когда дифф исполняемый, а связь ни с одним смоком не доказана. + * Раньше такой ответ был только текстом «решает ревьюер», и автор гонял одни + * названные в ТЗ смоки: CSS-правка #687 не выбрала `smoke_modes`, и `dev` + * остался красным для следующей задачи (#685). Набор фиксирован и короткий — + * локально меньше минуты, — чтобы «неопределённость» стоила прогона, а не + * полной матрицы: полная матрица остаётся гейтом `dev` и беты. + */ +export const VISUAL_MINIMUM = Object.freeze([ + 'smoke_modes.mjs', // режимы: иконки плана, панели, выход в вид (#687) + 'smoke_mode_transition.mjs', // вид ↔ редактор: высота тулбара, сцена, камера + 'smoke_hide_layers.mjs', // слои плана по флагам + 'smoke_decor_layer_order.mjs', // порядок слоёв декора + 'smoke_daycycle_zoom_layers.mjs', // слои дня/ночи при любом масштабе (#689) + 'smoke_static_zoom_sharpness.mjs', // свежий векторный кадр после зума (#685, #689) + 'smoke_wall_hatch_density.mjs', // штриховка стен в обоих рендерах + 'smoke_visual_continuity.mjs', // последний кадр плана не пропадает +]); + /** Файлы, чей дифф способен что-то сломать в браузере. */ const isExecutableFrontend = (file) => file.startsWith('src/') && file.endsWith('.ts') && !file.endsWith('.d.ts'); @@ -197,6 +217,8 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {}) const registered = registeredSmokes(parsed.symbols) .filter((entry) => !directNames.has(entry.smoke)); + const unproven = parsed.executable.length > 0 + && !direct.some((entry) => entry.strong) && !registered.length; return { files: parsed.files, executable: parsed.executable, @@ -211,8 +233,9 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {}) // нельзя молчать. `noExecutableDiff` от него отличается: там и правда // нечего проверять (docs, i18n-строки без кода, чистая инфраструктура). noExecutableDiff: parsed.executable.length === 0, - unproven: parsed.executable.length > 0 - && !direct.some((entry) => entry.strong) && !registered.length, + unproven, + // #690 п.1′: неопределённость не молчит и не отдаёт пустой набор. + visualMinimum: unproven ? [...VISUAL_MINIMUM] : [], }; } @@ -281,6 +304,9 @@ function report(selection) { + ' доказуемо.'); lines.push('Это не значит «смоки не нужны»: значит, что связь не доказана' + ' и решает ревьюер.'); + lines.push(`Визуальный минимум (${selection.visualMinimum.length}) — прогнать до S7;` + + ' `npm run gate:small -- --smokes` гоняет его сам (#690):'); + for (const smoke of selection.visualMinimum) lines.push(` demo/${smoke}`); if (selection.broad.length) { lines.push(`Широкие символы (есть почти везде, ничего не различают):` + ` ${selection.broad.slice(0, 10).join(', ')}`); diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index 3be23307..2cdad378 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -135,15 +135,16 @@ export { hasTrackLabel, trackFromLabels }; * сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих * признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по * себе признаком не являются: их носит и инфраструктурная задача после ревью. - * Метка `trivial` — признак сама по себе (r1 #632): короткий трек (PROCESS §5.1) - * идёт S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у - * него нет. Ускоренный инфраструктурный вход понятия трека не имеет, поэтому - * `trivial` на нём не бывает; `infra` рядом с ней — тематическая метка. + * Прежняя метка `trivial` — признак сама по себе (r1 #632): такие задачи шли + * S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у них + * нет. С #695 `trivial` читается как `track:show` (PROCESS §5.1), новым задачам + * не ставится, но на старых остаётся. Инфраструктурный вход её не несёт; `infra` + * рядом с ней — тематическая метка. */ export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) { const reasons = []; if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`); - if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)'); + if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)'); if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue'); if (specs.length) reasons.push('файл ТЗ в docs/specs'); if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ'); diff --git a/test/smoke-select.test.mjs b/test/smoke-select.test.mjs index 04ffe1d5..d5a52ef0 100644 --- a/test/smoke-select.test.mjs +++ b/test/smoke-select.test.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { selectSmokes, parseDiff, symbolTable } from '../scripts/smoke-select.mjs'; +import { selectSmokes, parseDiff, symbolTable, VISUAL_MINIMUM } from '../scripts/smoke-select.mjs'; +import { smokesToRun } from '../scripts/gate-small.mjs'; import { SMOKE_LINKS, registeredSmokes } from '../scripts/smoke-links.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -75,6 +77,30 @@ test('связь не доказана — это неопределённост 'символ без смока обязан быть назван'); }); +test('#690 п.1′: неопределённость выдаёт визуальный минимум, доказанная связь и docs — нет', () => { + const unproven = selectSmokes(fixture('unproven.diff'), { root: repoRoot }); + assert.deepEqual(unproven.visualMinimum, [...VISUAL_MINIMUM]); + for (const smoke of VISUAL_MINIMUM) { + assert.ok(smokesToRun(unproven).includes(smoke), `gate:small -- --smokes не гоняет ${smoke}`); + } + assert.deepEqual(selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot }).visualMinimum, [], + 'при доказанной связи минимум не нужен — выборка остаётся меньше матрицы'); + const docs = selectSmokes(fixture('docs-only.diff'), { root: repoRoot }); + assert.deepEqual(docs.visualMinimum, []); + assert.deepEqual(smokesToRun(docs), [], 'без исполняемого диффа смоков нет'); +}); + +test('#690 п.1′: визуальный минимум — 5–8 существующих смоков, и CLI его печатает', () => { + assert.ok(VISUAL_MINIMUM.length >= 5 && VISUAL_MINIMUM.length <= 8, `в минимуме ${VISUAL_MINIMUM.length}`); + for (const smoke of VISUAL_MINIMUM) assert.ok(existsSync(join(repoRoot, 'demo', smoke)), `нет demo/${smoke}`); + assert.ok(VISUAL_MINIMUM.includes('smoke_modes.mjs'), 'смок, которого не хватило #687'); + const cli = spawnSync(process.execPath, ['scripts/smoke-select.mjs', '--diff', 'test/fixtures/smoke-select/unproven.diff'], + { cwd: repoRoot, encoding: 'utf8' }); + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /Визуальный минимум \(\d\) — прогнать до S7/); + for (const smoke of VISUAL_MINIMUM) assert.ok(cli.stdout.includes(`demo/${smoke}`), smoke); +}); + test('таблица символов не берёт одиночные английские слова (#241)', () => { const table = symbolTable(repoRoot); for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) { diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index 1c95d428..bc824da0 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -198,7 +198,7 @@ test('#632 r1: trivial issue in S6/S7 keeps class A rights without any spec arte assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(',')); } assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['trivial'], issue: { body } }), - ['короткий трек trivial (ТЗ не пишется, §5.1)']); + ['прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)']); assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['small', 'infra'], issue: { body } }), [], 'только trivial: small несёт ТЗ в теле и доказывается разделом «## ТЗ»'); });