diff --git a/PROCESS.md b/PROCESS.md index 066c6594..206e8cdd 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -976,6 +976,9 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`, `houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`). +Профиль добавляет и правка того, что его меряет и судит без `src/**`: его +бюджетов и общего харнесса large-house — раннера, фикстуры, контракта карточки, +оценщика (#770). Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом. Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в diff --git a/demo/performance/README.md b/demo/performance/README.md index 13a54bde..5560829c 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -137,7 +137,15 @@ This is a catastrophic-regression guard, not a performance trend detector. Two more profiles join the smoke only when the diff touches their code path (#473, classified by `scripts/classify-changes.mjs`): `large-house-isometric-v1` for `src/iso-*` and `large-house-interaction-v1` for `src/live-*`, -`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Both +`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Since +#770 a profile also joins when the diff touches what measures or judges it +without `src/**`: its own budgets (the smoke file and the full profile's, which +the smoke repeats) and the shared large-house harness — +`benchmark_large_house.mjs`, `demo/fixtures/large-house.mjs`, +`card-contract.mjs` and `evaluate.mjs`, which bring in both. Before, a +budget-only change ran the smoke with Glow alone, and the new ceiling was first +judged by the beta candidate. The Glow runner and `compare.mjs` are exercised by +the Glow smoke, which always runs. Both run `--samples=3 --warmups=1` against `budgets-isometric-smoke.json` and `budgets-interaction-smoke.json`, whose ceilings are the `hardMaxMs` values of the full profiles. The set of profiles is part of the `performance_smoke` @@ -200,9 +208,9 @@ ceiling" and the dense twin equal to the historical profile. Smaller growth stays the job of the full workflow's base-relative comparison (unchanged: 0.35 flat, 0.2 2.5D, 250 ms noise allowance), and a floor built inside the window already fails the sample through the #735 structural guard, not through time. No -3-sample `performance_smoke` median is in the series yet: those profiles join -Validate only on a `src/**` diff, so the first beta candidate after #747 is -their check. +3-sample `performance_smoke` median is in the series yet: at the time those +profiles joined Validate only on a `src/**` diff, so the first beta candidate +after #747 is their check. The 2.5D View toggle (`viewToggleMs`) is reported by the isometric profiles but budgeted by none of them (#720, owner decision in #694 on 2026-09-30). Switching diff --git a/scripts/classify-changes.mjs b/scripts/classify-changes.mjs index 62c7daea..fb132869 100755 --- a/scripts/classify-changes.mjs +++ b/scripts/classify-changes.mjs @@ -36,12 +36,34 @@ export const CHECK_OF_OUTPUT = { /** * Профили перф-смока (#473 §5): изометрический — при правке изометрии, * профиль взаимодействия — при правке живого пути и оркестраторов кадра. - * Только `src/**`: тесты и демо кадр не замедляют. Это НЕ выбор job, а выбор + * Тесты кадр не замедляют и профиль не включают. Это НЕ выбор job, а выбор * набора внутри неё, поэтому остаётся фильтром по путям. + * + * #770: профиль включает и то, что меняет его замер или вердикт без `src/**`: + * его бюджеты — смоковый и полный (смок повторяет потолки полного, #473 AC4) — + * и общий харнесс large-house: раннер, его фикстуру, контракт приватной + * поверхности карточки и оценщик. Прежде правка одного потолка шла в Validate + * с одним glow, и новый потолок впервые судил кандидат беты. Glow-раннер и + * `compare.mjs` исполняет glow-смок, который идёт всегда. */ +const LARGE_HOUSE_HARNESS = [ + /^demo\/benchmark_large_house\.mjs$/, + /^demo\/fixtures\/large-house\.mjs$/, + /^demo\/performance\/(card-contract|evaluate)\.mjs$/, +]; +const anyOf = (...patterns) => new RegExp(patterns.map((pattern) => `(?:${pattern.source})`).join('|')); + export const PERF_PROFILES = { - perf_iso: /^src\/iso-[^/]+\.ts$/, - perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/, + perf_iso: anyOf( + /^src\/iso-[^/]+\.ts$/, + /^demo\/performance\/budgets-(isometric-smoke|large-house-isometric)\.json$/, + ...LARGE_HOUSE_HARNESS, + ), + perf_interaction: anyOf( + /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/, + /^demo\/performance\/budgets-(interaction-smoke|large-house-interaction)\.json$/, + ...LARGE_HOUSE_HARNESS, + ), }; /** Совместимость с прежним экспортом: имя выхода → предикат по файлу. */ diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 002a08d1..5399e997 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs, mutantsRequested, screenshotsGateMode, @@ -45,12 +45,90 @@ test('дифф по документации не включает ни одно assert.equal(out.backend, 'false'); }); -test('тесты и демо перф-профили не включают: кадр они не замедляют', () => { - const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs']); +test('тесты и чужие демо-файлы перф-профили не включают: кадр и замер они не меняют', () => { + // Glow-раннер и compare.mjs исполняет glow-смок, который идёт всегда; + // фикстура плотного двойника принадлежит профилю без смока. + const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_glow.mjs', + 'demo/performance/compare.mjs', 'demo/performance/isometric-stage3-dense-fixture.mjs']); assert.equal(out.perf_iso, 'false'); assert.equal(out.perf_interaction, 'false'); }); +// #770: смоковый профиль судят его бюджеты, а замеряет общий раннер +// large-house. Прежде профиль включал только `src/**`, и правка одного потолка +// шла в Validate с одним glow: новый потолок впервые судил кандидат беты. +// Профиль шага берётся из самого validate.yml (его `--budgets`), бюджеты +// профиля — из demo/performance по полю `profile`: новый файл бюджета этого +// профиля обязан включать его без правки теста. +const VALIDATE = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); +const PERF_DIR = new URL('../demo/performance/', import.meta.url); +const BUDGETS = readdirSync(PERF_DIR).filter((name) => /^budgets[\w-]*\.json$/.test(name)) + .map((name) => ({ path: `demo/performance/${name}`, profile: JSON.parse(readFileSync(new URL(name, PERF_DIR), 'utf8')).profile })); +const PERF_STEPS = { perf_iso: 'Изометрический профиль по диффу', perf_interaction: 'Профиль взаимодействия по диффу' }; +const smokeProfileOf = (output) => { + const job = VALIDATE.slice(VALIDATE.indexOf('\n performance_smoke:\n'), VALIDATE.indexOf('\n geometry_parity:\n')); + const start = job.indexOf(PERF_STEPS[output]); + const step = job.slice(start, job.indexOf('- name:', start)); + assert.ok(start > 0, `${output}: нет шага «${PERF_STEPS[output]}»`); + assert.match(step, new RegExp(`if: needs\\.changes\\.outputs\\.${output} == 'true'`)); + const smokeBudget = step.match(/--absolute-only --budgets=(demo\/performance\/[\w-]+\.json)/)[1]; + const { profile } = BUDGETS.find((budget) => budget.path === smokeBudget); + assert.ok(step.includes(`--profile=${profile} `), `${output}: шаг меряет тот профиль, который судит его бюджет`); + return { profile, smokeBudget }; +}; +// Набор профилей ключа реюза — тем же текстом, что исполняет шаг `keys` job +// `reuse`: `set=glow` и по строке `[ "$PERF_X" = "true" ] && set="$set-…"`. +const REUSE = VALIDATE.slice(VALIDATE.indexOf('\n reuse:\n'), VALIDATE.indexOf('\n hacs:\n')); +const perfSetOf = (outputs) => { + const envOutput = Object.fromEntries([...REUSE.matchAll(/(PERF_\w+): \$\{\{ needs\.changes\.outputs\.(\w+) \}\}/g)] + .map(([, env, output]) => [env, output])); + let set = REUSE.match(/^\s*set=(\w+)$/m)[1]; + for (const [, env, suffix] of REUSE.matchAll(/\[ "\$(PERF_\w+)" = "true" \] && set="\$set-(\w+)"/g)) { + if (outputs[envOutput[env]] === 'true') set = `${set}-${suffix}`; + } + return set; +}; + +test('#770: правка только бюджета включает профиль, который он судит, и меняет ключ реюза', () => { + const quiet = perfSetOf(classify([p('docs', 'SUN.md')])); + assert.equal(quiet, 'glow', 'без перф-диффа набор — один glow'); + for (const output of Object.keys(PERF_STEPS)) { + const { profile, smokeBudget } = smokeProfileOf(output); + const judged = BUDGETS.filter((budget) => budget.profile === profile).map((budget) => budget.path); + // Смоковый и полный: смок повторяет потолки полного (#473 AC4), и правка + // полного — правка того же профиля. + assert.ok(judged.includes(smokeBudget) && judged.length >= 2, `${profile}: ${judged.join(', ')}`); + for (const path of judged) { + const out = classify([path]); + assert.equal(out[output], 'true', `${path} судит ${profile} — профиль обязан войти в смок`); + for (const other of Object.keys(PERF_STEPS).filter((name) => name !== output)) + assert.equal(out[other], 'false', `${path} не судит профиль ${other}`); + assert.deepEqual(out.unknown, [], path); + // Обе половины ключа `reuse-performance_smoke-<входы>-<набор>` меняются: + // файл — вход job (хеш), а набор получает профиль. + assert.ok(MANIFEST.performance_smoke.has(path), `${path}: вход хеша performance_smoke`); + assert.notEqual(perfSetOf(out), quiet, `${path}: ключ реюза совпал бы с glow-only прогоном`); + } + } + // Бюджеты профилей без смока (полные и glow) смоковых профилей не включают. + const smokeProfiles = new Set(Object.keys(PERF_STEPS).map((output) => smokeProfileOf(output).profile)); + for (const budget of BUDGETS.filter(({ profile }) => !smokeProfiles.has(profile))) { + const out = classify([budget.path]); + for (const output of Object.keys(PERF_STEPS)) assert.equal(out[output], 'false', `${budget.path} → ${output}`); + } +}); + +test('#770: раннер large-house, его фикстура, контракт карточки и оценщик включают оба смоковых профиля', () => { + for (const file of ['demo/benchmark_large_house.mjs', 'demo/fixtures/large-house.mjs', + 'demo/performance/card-contract.mjs', 'demo/performance/evaluate.mjs']) { + const out = classify([file]); + assert.equal(out.perf_iso, 'true', file); + assert.equal(out.perf_interaction, 'true', file); + assert.equal(perfSetOf(out), 'glow-iso-interaction', file); + assert.deepEqual(out.unknown, [], file); + } +}); + test('правка реестра мутантов даёт mutants=true; юниты реестра — тоже вход frontend (#475 r1, #492)', () => { for (const file of ['scripts/mutation-gate.mjs', 'scripts/mutation-registry.mjs', 'scripts/mutation-selection.mjs', 'scripts/mutation-evidence.mjs',