process: an unproven smoke link runs the visual minimum; raster defects need a witness (#690)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-29 08:45:44 +03:00
parent 6e07313460
commit 52a56430ab
9 changed files with 127 additions and 18 deletions
+9 -3
View File
@@ -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} — решает ревьюер, автоматически не гоняются`);
}
+36 -2
View File
@@ -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 '
+28 -2
View File
@@ -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(', ')}`);
+6 -5
View File
@@ -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('документ ревью ТЗ');