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
+40
View File
@@ -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');
+54 -1
View File
@@ -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$/);
+2
View File
@@ -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`'));