fix(ci): close smoke selection and persisted-type risk blind spots

Issue: #772
User-Visible: no
This commit is contained in:
Matysh
2026-10-01 19:05:09 +03:00
parent 1f15ee143e
commit 50b163c60c
9 changed files with 217 additions and 28 deletions
+29 -12
View File
@@ -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}`);
+44
View File
@@ -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="держится на зарегистрированной связи" '
+18 -6
View File
@@ -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);
}
+10 -7
View File
@@ -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;`
+3 -2
View File
@@ -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; }