diff --git a/docs/TESTING.md b/docs/TESTING.md index 6aaf53ca..a0b24c7e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -320,6 +320,23 @@ node scripts/pre-push-gate.mjs --max-smokes=3 `--smokes` гоняет вместе с выборкой (#690). CSS-правка #687 без него не выбрала `smoke_modes`. +С #772 реестр `scripts/smoke-links.mjs` принимает и `files`: точные пути +таблиц стилей, где изменение не называет TS-символ. Так правка +`src/styles/plan.styles.ts` всегда добавляет `smoke_room_fill_transitions`. +Отчёт и пакет задачи печатают путь с пометкой «(файл)». Такая широкая связь +сама по себе **не отменяет визуальный минимум**. Вложенные свойства литерала +аргумента (`key: { … }` или `key: [ … ]`) тоже приписываются вызову, но поиск +не пересекает тело отдельной функции и не расширяет трёхстрочный контекст. + +Риск `migration` проверяет также именованные сохраняемые типы вне `types.ts` +(`PERSISTED_TYPES` в `scripts/change-risk.mjs`): для лестниц — `Stair`, +его варианты, общий контракт и направления. При переносе сохраняемой +декларации в смешанный модуль этот реестр дополняют; импорт того же имени или +соседний локальный тип не делает модуль целиком участком `migration`. +Регрессии проверяют и класс риска, и итоговое решение `ship` → `show`. +Мутанты этих границ зарегистрированы для ночного прогона; в задаче — только +статическая проверка якорей `node scripts/mutation-gate.mjs --check` (#709). + ### В хуке — по умолчанию для веток задач (#633) Прежде набор включался только переменной `HP_PREPUSH_GATE=1`, и ошибки, которые diff --git a/scripts/change-risk.mjs b/scripts/change-risk.mjs index dea3ed52..78bf3e1c 100644 --- a/scripts/change-risk.mjs +++ b/scripts/change-risk.mjs @@ -10,7 +10,8 @@ // токенами: ни один шаблон участка их не задевает, и тест держит это // («монолит участком не судится»). Пустые строки, комментарии, строки импорта и // строки только типов TypeScript риска не дают (#755) — кроме файлов участка -// `migration`, где типы конфига и есть контракт. +// `migration` и явно перечисленных сохраняемых типов в смешанных модулях +// (#772), где типы конфига и есть контракт. // // Таблица — эвристика (ТЗ #707 §10 п.2): пути и токены меняются свободно, // каждая строка покрыта положительным и отрицательным случаем в @@ -108,6 +109,15 @@ export function isCommentOrBlank(text, file = '') { */ const MODULE_LINE = /^(?:import\s|export\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*['"]|\}\s*from\s*['"])/; const TYPE_LINE = /^(?:export\s+(?:declare\s+)?type\s|(?:export\s+)?(?:declare\s+)?(?:interface\s+[\w$]|type\s+[\w$]+\s*(?:<.*>)?\s*=))/; +const TYPE_NAME = /^(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+([\w$]+)/; +// #772: types.ts ссылается на Stair; сохраняется вся цепочка его деклараций, +// но не StairVisualStyle, StairRenderGeometry или кеши из того же модуля. +// При выносе сохраняемого типа из types.ts сюда добавляют путь и декларации, +// с положительным примером и соседним несохраняемым типом в process-track.test. +const PERSISTED_TYPES = { + 'src/stairs.ts': new Set(['Stair', 'StairCommon', 'StraightStair', 'SpiralStair', + 'StraightStairDirection', 'SpiralStairDirection']), +}; /** Строка-оператор, после которой блок декларации ещё открыт: `import {`, `interface X {`, `type X =`. */ const OPENS_BLOCK = /[{=(<,|&]\s*$/; export const isModuleOrTypeStatement = (text) => MODULE_LINE.test(text) || TYPE_LINE.test(text); @@ -122,29 +132,34 @@ const opensBlock = (text) => isModuleOrTypeStatement(text) && OPENS_BLOCK.test(t * строка без отступа внутри блока. Так член интерфейса под заголовком * `@@ … @@ export interface X {` и целиком добавленный интерфейс судятся одинаково. */ -export function moduleOrTypeRows(rows = []) { - const out = new Set(); +function moduleOrTypeDeclarations(rows = []) { + const out = new Map(); const open = new Map(); + // null — вне декларации; пустая строка — импорт/реэкспорт, не именованный тип. + const state = (text) => (opensBlock(text) ? (TYPE_NAME.exec(text)?.[1] || '') : null); rows.forEach((row, i) => { const key = `${row.block ?? 0}${row.side}`; - if (!open.has(key)) open.set(key, opensBlock(row.ctx ?? '')); + if (!open.has(key)) open.set(key, state(row.ctx ?? '')); const text = String(row.text); if (!text || /^\s/.test(text)) { - if (open.get(key)) out.add(i); + if (open.get(key) !== null) out.set(i, open.get(key)); return; } if (/^(?:\/\/|\/\*|\*)/.test(text)) return; - if (open.get(key) && /^[}\])>]/.test(text)) { - out.add(i); - open.set(key, false); + if (open.get(key) !== null && /^[}\])>]/.test(text)) { + out.set(i, open.get(key)); + open.set(key, null); return; } - if (isModuleOrTypeStatement(text)) out.add(i); - open.set(key, opensBlock(text)); + if (isModuleOrTypeStatement(text)) out.set(i, TYPE_NAME.exec(text)?.[1] || ''); + open.set(key, state(text)); }); return out; } +/** Номера строк модулей/типов; имена деклараций остаются внутренней деталью. */ +export const moduleOrTypeRows = (rows = []) => new Set(moduleOrTypeDeclarations(rows).keys()); + /** * Разбор `git diff --unified=0` (подходит и с контекстом): файлы и их изменённые * строки с номерами. Удалённая строка несёт номер и путь старой стороны, @@ -231,17 +246,19 @@ export function classifyRisk(diffText = '') { const key = JSON_KEY.exec(row.text); if (key) removedKeys.add(key[1]); } - const typeOnly = moduleOrTypeRows(file.lines); + const typeOnly = moduleOrTypeDeclarations(file.lines); // Добавленная строка блока по `at`: пара для удалённой — одна заменённая строка. const addedAt = new Map(file.lines.filter((r) => r.side === '+').map((r) => [`${r.block}:${r.at}`, r])); for (const [i, row] of file.lines.entries()) { const p = row.side === '-' ? file.oldPath : file.newPath; if (!p || classify(p) !== 'A' || isCommentOrBlank(row.text, p)) continue; // #755: типы конфига — контракт, в участке migration строки типов судятся. - if (typeOnly.has(i) && p.endsWith('.ts') && !AREAS.migration.some((r) => r.re.test(p))) continue; + const persistedType = PERSISTED_TYPES[p]?.has(typeOnly.get(i)) ? typeOnly.get(i) : null; + if (typeOnly.has(i) && p.endsWith('.ts') && !persistedType && !AREAS.migration.some((r) => r.re.test(p))) continue; const where = { path: p, line: row.line, side: row.side }; const pair = row.side === '-' && file.newPath ? addedAt.get(`${row.block}:${row.at}`) : null; if (pair) where.pair = `+${file.newPath}:${pair.line}`; + if (persistedType) add('migration', where, `сохраняемый тип ${persistedType}`); for (const [cls, rules] of Object.entries(AREAS)) { const rule = rules.find((r) => r.re.test(p)); if (rule) add(cls, where, `участок ${rule.label}`); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index b619dc8b..22d4a2fb 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5894,6 +5894,50 @@ const MUTANT_DEFINITIONS = [ replace: ' ? (text) => text', }], }, + { + id: 'risk-counts-module-and-local-type-rows', + guard: 'node --test --test-name-pattern="#755 AC1|#772" test/process-track.test.mjs', + because: '#772 / #755 К1: imports and non-persisted TypeScript declarations must not ' + + 'raise ship to show; the stored-type exception must not disable that guard', + patches: [{ + file: 'scripts/change-risk.mjs', + find: " if (typeOnly.has(i) && p.endsWith('.ts') && !persistedType && !AREAS.migration.some((r) => r.re.test(p))) continue;", + replace: ' // mutant: module/type-only rows are judged as executable code', + }], + }, + { + id: 'risk-ignores-persisted-stair-types', + guard: 'node --test --test-name-pattern="#772" test/process-track.test.mjs', + because: '#772: a small stored-field change outside types.ts must raise an unconfirmed ' + + 'ship to show; ignoring StairCommon silently merges an unread schema contract', + patches: [{ + file: 'scripts/change-risk.mjs', + find: ' const persistedType = PERSISTED_TYPES[p]?.has(typeOnly.get(i)) ? typeOnly.get(i) : null;', + replace: ' const persistedType = null; // mutant: stored contract ignored', + }], + }, + { + id: 'smoke-select-ignores-style-file-links', + guard: 'node --test --test-name-pattern="#772: CSS" test/smoke-select.test.mjs', + because: '#772: changing a room CSS rule without a TypeScript symbol must still select ' + + 'smoke_room_fill_transitions; the generic visual minimum does not assert this contract', + patches: [{ + file: 'scripts/smoke-select.mjs', + find: ' const registered = registeredSmokes(parsed.symbols, parsed.executable)', + replace: ' const registered = registeredSmokes(parsed.symbols)', + }], + }, + { + id: 'smoke-select-stops-at-nested-property', + guard: 'node --test --test-name-pattern="#772: вложенные" test/smoke-select.test.mjs', + because: '#772: a nested object or array property is still part of the argument; ' + + 'stopping at its colon loses the enclosing function and its linked smoke', + patches: [{ + file: 'scripts/smoke-select.mjs', + find: " if (!['(', ',', '[', ':'].includes(previous(row, column))) return null;", + replace: " if (!['(', ',', '['].includes(previous(row, column))) return null;", + }], + }, { id: 'smoke-select-drops-registered-link', guard: 'node --test --test-name-pattern="держится на зарегистрированной связи" ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index fe3543ae..2403c397 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -1,5 +1,5 @@ /** - * Явные связи «символ продуктового кода → browser-smoke» (#241). + * Явные связи «символ или файл продуктового кода → browser-smoke» (#241, #772). * * Зачем реестр вообще нужен. Смоки не импортируют исходники: они грузят * собранный бандл и работают через DOM и приватные поля в `page.evaluate`. @@ -20,13 +20,22 @@ /** * @typedef {object} SmokeLink - * @property {string[]} symbols Изменённые символы, включающие связь. + * @property {string[]} [symbols] Изменённые символы, включающие связь. + * @property {string[]} [files] Точные пути файлов без символов (CSS). Дополняют + * выборку, но сами по себе не отменяют визуальный минимум: файл шире контракта. * @property {string[]} smokes Файлы в `demo/`, без пути. * @property {string} because Что смок проверяет и почему поиском не найдётся. */ /** @type {SmokeLink[]} */ export const SMOKE_LINKS = [ + { + files: ['src/styles/plan.styles.ts'], + smokes: ['smoke_room_fill_transitions.mjs'], + because: '#772 / #746: переход заливки комнаты зависит от CSS .room, а не только ' + + 'от TS-резолвера; имён функций в правке CSS нет. Связь со всей таблицей ' + + 'стилей намеренно шире селектора и сохраняет страховочный визуальный минимум', + }, { symbols: [ 'isoPlaneMatrix', 'unprojectFloorPoint', '_convertProjectionView', '_rezoom', @@ -614,16 +623,19 @@ export const SMOKE_LINKS = [ }, ]; -/** Смоки, связанные с изменёнными символами через реестр. */ -export function registeredSmokes(changedSymbols) { +/** Смоки, связанные с изменёнными символами или точными путями через реестр. */ +export function registeredSmokes(changedSymbols, changedFiles = []) { const changed = new Set(changedSymbols); + const files = new Set(changedFiles); const out = new Map(); for (const link of SMOKE_LINKS) { - const hit = link.symbols.filter((symbol) => changed.has(symbol)).sort(); - if (!hit.length) continue; + const hit = (link.symbols || []).filter((symbol) => changed.has(symbol)).sort(); + const fileHit = (link.files || []).filter((file) => files.has(file)).sort(); + if (!hit.length && !fileHit.length) continue; for (const smoke of link.smokes) { const entry = out.get(smoke) || { smoke, symbols: [], because: [] }; entry.symbols = [...new Set([...entry.symbols, ...hit])].sort(); + if (fileHit.length) entry.files = [...new Set([...(entry.files || []), ...fileHit])].sort(); if (!entry.because.includes(link.because)) entry.because.push(link.because); out.set(smoke, entry); } diff --git a/scripts/smoke-select.mjs b/scripts/smoke-select.mjs index 6a17618c..cd1d7d6d 100644 --- a/scripts/smoke-select.mjs +++ b/scripts/smoke-select.mjs @@ -128,7 +128,8 @@ const CALLEE_NAME = /([A-Za-z_$][\w$]*)\s*(?:\?\.)?$/; /** * Имя функции, внутри аргументов которой начинается строка `index` (#754): * ближайшая незакрытая `(` выше в пределах ханка. Литерал-аргумент — `{` или - * `[` после `(`, `,` или `[` — проходится насквозь; `;` на нулевой глубине и + * `[` после `(`, `,`, `[` или `:` (#772: вложенное свойство) — проходится + * насквозь; `;` на нулевой глубине и * любая другая незакрытая `{`/`[` (тело блока, функции, класса, присваивание * литерала) останавливают поиск. `lines` уже вычищены `scrubLine`. */ @@ -152,7 +153,7 @@ function enclosingCallee(lines, index) { if (!(char in opener)) continue; if (depth[opener[char]] > 0) { depth[opener[char]]--; continue; } if (char === '(') return CALLEE_NAME.exec(text.slice(0, column).trimEnd())?.[1] ?? null; - if (!['(', ',', '['].includes(previous(row, column))) return null; + if (!['(', ',', '[', ':'].includes(previous(row, column))) return null; } } return null; @@ -299,11 +300,13 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {}) || b.count - a.count || a.smoke.localeCompare(b.smoke)); const directNames = new Set(direct.map((entry) => entry.smoke)); - const registered = registeredSmokes(parsed.symbols) + const registered = registeredSmokes(parsed.symbols, parsed.executable) .filter((entry) => !directNames.has(entry.smoke)); + // #772: связь по целому файлу дополняет проверки, но не доказывает, что она + // покрывает именно изменённый контракт; прежний визуальный минимум остаётся. const unproven = parsed.executable.length > 0 - && !direct.some((entry) => entry.strong) && !registered.length; + && !direct.some((entry) => entry.strong) && !registered.some((entry) => entry.symbols.length); return { files: parsed.files, executable: parsed.executable, @@ -387,15 +390,15 @@ function report(selection) { lines.push(`Зарегистрированная связь (${selection.registered.length}):`); for (const entry of selection.registered) { lines.push(` demo/${entry.smoke}`); - lines.push(` ← ${named(entry.symbols)}`); + lines.push(` ← ${[named(entry.symbols), ...(entry.files || []).map((file) => `${file} (файл)`)].filter(Boolean).join(', ')}`); for (const because of entry.because) lines.push(` ${because}`); } lines.push(''); } if (selection.unproven) { - lines.push('НЕОПРЕДЕЛЁННОСТЬ: дифф исполняемый, но ни один смок не связан' - + ' доказуемо.'); + lines.push('НЕОПРЕДЕЛЁННОСТЬ: дифф исполняемый, но точная связь со смоком' + + ' не доказана (привязка целого файла лишь дополняет проверки).'); lines.push('Это не значит «смоки не нужны»: значит, что связь не доказана' + ' и решает ревьюер.'); lines.push(`Визуальный минимум (${selection.visualMinimum.length}) — прогнать до S7;` diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index e03e59f5..22433c5d 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -260,7 +260,8 @@ export function requiredChecks({ risk = null, changedFiles = [], smokes = null, out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: прямое совпадение (${entry.symbols.slice(0, 4).join(', ')})` }); } for (const entry of smokes?.registered || []) { - out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: зарегистрированная связь (${entry.symbols.slice(0, 4).join(', ')})` }); + const evidence = [...entry.symbols.slice(0, 4), ...(entry.files || []).map((file) => `${file} (файл)`)]; + out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: зарегистрированная связь (${evidence.join(', ')})` }); } if (smokes?.visualMinimum?.length) { out.push({ command: '`npm run gate:small -- --smokes`', reason: `smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): ${smokes.visualMinimum.map((s) => s.replace(/\.mjs$/, '')).join(', ')}` }); @@ -521,7 +522,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr const selection = selectSmokes(sh('git', ['-c', 'core.quotePath=false', 'diff', `--unified=${CALL_CONTEXT_LINES}`, '-M', '--no-color', '--no-ext-diff', '--no-textconv', `${base}..${ref}`], { cwd })); smokes = { direct: selection.direct.filter((entry) => entry.strong).map(({ smoke, symbols }) => ({ smoke, symbols })), - registered: selection.registered.map(({ smoke, symbols }) => ({ smoke, symbols })), + registered: selection.registered.map(({ smoke, symbols, files }) => ({ smoke, symbols, ...(files ? { files } : {}) })), visualMinimum: selection.visualMinimum, }; } catch { smokes = null; } diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index 49153a80..f08e7f76 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -363,6 +363,46 @@ test('#755 AC1: строки импорта и только типов TypeScrip assert.deepEqual(classifyRisk(diffOf([{ path: 'custom_components/houseplan/auth.py', add: [[2, 'import hass']] }])).classes, ['devices']); }); +test('#772: сохраняемые типы лестниц вне types.ts дают migration и повышают ship', () => { + const path = 'src/stairs.ts'; + const variants = [ + ['@@ -45 +45 @@ interface StairCommon {', '- fill_color?: string;', '+ fill_color: string;'], + ['@@ -67 +67 @@ export interface StraightStair extends StairCommon {', '- width: number;', '+ width?: number;'], + ['@@ -74 +74 @@ export interface SpiralStair extends StairCommon {', '- radius: number;', '+ radius?: number;'], + ['@@ -33 +33 @@', "-export type StraightStairDirection = 'forward' | 'backward';", "+export type StraightStairDirection = 'up' | 'down';"], + ['@@ -34 +34 @@', "-export type SpiralStairDirection = 'clockwise' | 'counterclockwise';", "+export type SpiralStairDirection = 'cw' | 'ccw';"], + ['@@ -77 +77 @@', '-export type Stair = StraightStair | SpiralStair;', '+export type Stair = StraightStair;'], + ['@@ -37,0 +38,3 @@', '+interface StairCommon {', '+ color: string;', '+}'], + ['@@ -37,3 +37,0 @@', '-interface StairCommon {', '- color: string;', '-}'], + ]; + for (const hunk of variants) { + const diff = hunksOf(path, [hunk]); + const result = decideTrack({ stage: 'code', branch: 'issue/772-probe', labels: ['track:ship'], + files: [path], numstat: [{ path, added: 3, deleted: 3 }], nameStatus: [{ path, status: 'M' }], diff }); + assert.deepEqual(result.violations, [], 'правка укладывается в механические рамки'); + assert.ok(result.risk.classes.includes('migration'), hunk.join('\n')); + assert.match(result.risk.evidence.migration[0], /src\/stairs\.ts:\d+.*сохраняемый тип/); + assert.equal(result.track, 'show'); + assert.equal(result.ship, false); + assert.equal(result.raise, true); + } + // Имя сохраняемого типа в импорте или в другом модуле не делает его декларацией. + const safe = [ + [path, ['@@ -1 +1 @@', "-import { StairCommon } from './x';", "+import type { StairCommon } from './x';"]], + [path, ['@@ -1 +1 @@ import {', '- StairCommon,', '+ StairCommon, Stair,']], + [path, ['@@ -1 +1 @@ export interface StairVisualStyle {', '- color: string;', '+ color?: string;']], + [path, ['@@ -1 +1 @@ type CachedRenderGeometry = {', '- fingerprint: string;', '+ fingerprint?: string;']], + ['src/iso-scene-render.ts', ['@@ -1 +1 @@ interface StairCommon {', '- color: string;', '+ color?: string;']], + ]; + for (const [file, hunk] of safe) { + const result = decideTrack({ stage: 'code', branch: 'issue/772-probe', labels: ['track:ship'], + files: [file], numstat: [{ path: file, added: 1, deleted: 1 }], nameStatus: [{ path: file, status: 'M' }], + diff: hunksOf(file, [hunk]) }); + assert.deepEqual(result.risk.classes, [], hunk.join('\n')); + assert.equal(result.ship, true); + } +}); + test('#755 AC2: участки stairs и config — модель лестницы и запись конфига, а не всё по префиксу', () => { const cls = (path, text = ' const a = b + c;') => classifyRisk(diffOf([{ path, add: [[5, text]] }])).classes; assert.deepEqual(cls('src/stairs-view.ts'), ['visual'], 'отрисовка лестницы — visual:render, не geometry'); diff --git a/test/smoke-select.test.mjs b/test/smoke-select.test.mjs index 4d0cce9b..6b02d929 100644 --- a/test/smoke-select.test.mjs +++ b/test/smoke-select.test.mjs @@ -186,6 +186,55 @@ test('#754 AC1: вызов ищется сквозь литерал-аргуме assert.deepEqual(named.symbols, ['resolveThing']); }); +test('#772: вложенные свойства аргумента сохраняют вызов, но не пересекают тело функции', () => { + const hunk = (...lines) => parseDiff([ + 'diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts', + '@@ -1,5 +1,5 @@', ...lines, + ].join('\n'), new Set(['resolveThing'])); + for (const property of ['key: {', 'key: [', "'key': {", '[key]: {']) { + const result = hunk(' resolveThing({', ` ${property}`, '- enabled: false,', '+ enabled: true,'); + assert.deepEqual(result.callees, ['resolveThing'], property); + } + for (const boundary of [ + ' resolveThing(items, () => {', + ' resolveThing(items, function callback() {', + ' resolveThing({ method() {', + ' resolveThing(items); const options = {', + ]) { + const result = hunk(boundary, ' key: {', '- enabled: false,', '+ enabled: true,'); + assert.deepEqual(result.symbols, [], boundary); + } + const selection = selectSmokes([ + 'diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts', + '@@ -1,5 +1,5 @@', ' resolveIsoOverlayFitEnvelope({', ' stageSize: {', + '- width: 100,', '+ width: 200,', ' },', ' });', + ].join('\n'), { root: repoRoot }); + assert.deepEqual(selection.callees, ['resolveIsoOverlayFitEnvelope']); + assert.ok(smokesToRun(selection).includes('smoke_iso_flat_parity.mjs')); +}); + +test('#772: CSS комнаты выбирает room-fill smoke без символов и сохраняет визуальный минимум', () => { + const diff = [ + 'diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts', + '--- a/src/styles/plan.styles.ts', '+++ b/src/styles/plan.styles.ts', + '@@ -1,3 +1,3 @@', ' .room {', '- transition: none;', + '+ transition: fill 180ms;', ' }', + ].join('\n'); + const selection = selectSmokes(diff, { root: repoRoot }); + assert.deepEqual(selection.symbols, []); + const link = selection.registered.find((entry) => entry.smoke === 'smoke_room_fill_transitions.mjs'); + assert.deepEqual(link?.files, ['src/styles/plan.styles.ts']); + assert.ok(smokesToRun(selection).includes('smoke_room_fill_transitions.mjs')); + // Привязка всего файла — широкая: она не отменяет прежние безопасные проверки. + assert.deepEqual(selection.visualMinimum, [...VISUAL_MINIMUM]); + assert.ok(!smokesToRun(selectSmokes(diff.replaceAll('plan.styles.ts', 'dialogs.styles.ts'), { root: repoRoot })) + .includes('smoke_room_fill_transitions.mjs'), 'чужая таблица стилей не выбирает room-fill'); + const cli = spawnSync(process.execPath, [join(repoRoot, 'scripts/smoke-select.mjs'), '--diff', '-'], + { input: diff, encoding: 'utf8' }); + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /smoke_room_fill_transitions\.mjs[\s\S]*src\/styles\/plan\.styles\.ts \(файл\)/); +}); + test('таблица символов не берёт одиночные английские слова (#241)', () => { const table = symbolTable(repoRoot); for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) { @@ -210,7 +259,11 @@ test('parseDiff читает только исполняемый frontend (#241) test('каждая запись реестра объясняет себя и указывает на существующий смок (#241)', () => { for (const link of SMOKE_LINKS) { - assert.ok(link.symbols.length, 'связь без символов не сработает никогда'); + assert.ok(link.symbols?.length || link.files?.length, 'связь без символов или файлов не сработает никогда'); + for (const file of link.files || []) { + assert.match(file, /^src\/.*\.ts$/); + assert.ok(readFileSync(join(repoRoot, file), 'utf8').length, `${file} в реестре, но файла нет`); + } assert.ok(link.because && link.because.length > 40, 'связь без объяснения — суеверие'); for (const smoke of link.smokes) { assert.match(smoke, /^smoke_.*\.mjs$/); diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index 1f73a85d..d30fede5 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -369,6 +369,8 @@ test('#707 AC10: обязательные проверки с основания '`node demo/smoke_b.mjs` · smoke-select: зарегистрированная связь (_b)', '`npm run gate:small -- --smokes` · smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): smoke_modes', ]); + const styles = checksOf({ smokes: { registered: [{ smoke: 'smoke_room_fill_transitions.mjs', symbols: [], files: ['src/styles/plan.styles.ts'] }] } }); + assert.equal(styles[1].reason, 'smoke-select: зарегистрированная связь (src/styles/plan.styles.ts (файл))', '#772: пакет объясняет связь без символов'); assert.ok(commands(checksOf({ diff: GEOMETRY })).includes('`npm run invariants -- --config <экспорт>`'), 'invariants при geometry'); assert.ok(!commands(checksOf({ diff: TOUCH })).some((c) => c.includes('invariants')), 'без geometry — нет'); assert.ok(commands(checksOf({ changedFiles: ['custom_components/houseplan/store.py'] })).includes('`python -m pytest tests_backend -q`'));