mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
test: не считать дословный перенос новым any (#592)
Validate вернул задачу с красным `no-new-any`: шесть строк в новых модулях диалогов. Все шесть перенесены побайтово из редакторского рантайма — `as any` в них написан годы назад и в этом диапазоне не менялся. Счёт долга это подтверждает: явных `any` в `src/**` было 887 и осталось 887. Гейт мерил «добавленные строки», и для извлечения подсистемы такая мера ложна: она требует типизации ровно там, где ничего не изменилось. Причём единственный способ её удовлетворить — дописать `any-ok` в перенесённое тело, то есть сломать доказательство переноса (тела обязаны совпадать побайтово) ради зелёного гейта. Это починка задом наперёд. Теперь строка, дословно удалённая из одного файла диапазона и добавленная в другой, новым кодом не считается. Бюджет ведётся мультимножеством: два добавления при одном удалении оставляют одну находку, а перенос с изменённым отступом переносом не считается — это уже правка. Отчёт печатает, сколько строк признано перенесёнными, чтобы послабление было видно, а не подразумевалось. Гейт #342 при этом остаётся собой: новый `any` без удаления-близнеца — находка, и три новых теста проверяют обе стороны. Мутант `no-new-any-counts-every-added-line-as-moved` признаёт перенесённой любую добавленную строку — заявленный тест на этом краснеет. Отклонение от скоупа ТЗ: `scripts/no-new-any.mjs` в нём не назван. Альтернатива — шесть `any-ok` в перенесённом коде — стоила бы побайтовой сверки, на которой держится вся приёмка задачи. Issue: #592 User-Visible: no
This commit is contained in:
@@ -10135,6 +10135,18 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') return nothing;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'no-new-any-counts-every-added-line-as-moved',
|
||||
guard: 'node --test test/no-new-any.test.mjs',
|
||||
because: '#592: послабление для переноса держится на точном совпадении текста и бюджете '
|
||||
+ 'удалений. Если признать перенесённой любую добавленную строку, гейт #342 перестанет '
|
||||
+ 'ловить новый any вовсе — и сделает это молча, оставшись зелёным',
|
||||
patches: [{
|
||||
file: 'scripts/no-new-any.mjs',
|
||||
find: ' const budget = removed.get(item.text) || 0;\n if (!budget) continue;',
|
||||
replace: ' const budget = removed.get(item.text) || Number(item.line >= 0);\n if (!budget) continue;',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
+62
-2
@@ -27,6 +27,14 @@
|
||||
* конкретной: голый маркер, пустая или шаблонная причина («todo», «потом»,
|
||||
* «надо») гейт не проходят. Формулировка вида «внешний контракт HA не
|
||||
* типизирован» проходит.
|
||||
*
|
||||
* Второе исключение — перенос (#592). Строка, которая в этом же диапазоне
|
||||
* удалена из одного файла и добавлена в другой дословно, новым кодом не
|
||||
* является: ответственность за её тип не менялась, и долг в `src/**` не вырос.
|
||||
* Гейт считает такие строки перенесёнными и не судит их, но бюджет ведёт
|
||||
* мультимножеством: два добавления при одном удалении дают одну находку.
|
||||
* Без этого любое извлечение подсистемы — то самое, чем долг и снимается по
|
||||
* замыслу #342, — краснит гейт ровно за то, что ничего не изменило.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
@@ -84,6 +92,7 @@ export function findNewAnyViolations({ files }) {
|
||||
const textLines = String(file.text).split('\n');
|
||||
for (const [line, count] of anyKeywordLines(file.path, file.text)) {
|
||||
if (!file.addedLines.has(line)) continue;
|
||||
if (file.movedLines?.has(line)) continue;
|
||||
const lineText = textLines[line - 1] ?? '';
|
||||
const exemption = parseAnyOk(lineText);
|
||||
if (exemption?.ok) continue;
|
||||
@@ -126,6 +135,51 @@ export function addedLinesByFile(diff) {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строки, добавленные одним файлом и дословно удалённые другим (#592).
|
||||
*
|
||||
* Возвращает по файлу номера таких строк. Сравнение точное, без обрезки
|
||||
* пробелов: перенос с изменением отступа — уже правка, и судить её гейт обязан.
|
||||
* Бюджет ведётся мультимножеством: одно удаление покрывает одно добавление.
|
||||
*/
|
||||
export function movedLinesByFile(diff) {
|
||||
const removed = new Map();
|
||||
const added = [];
|
||||
let current = null;
|
||||
let next = 0;
|
||||
for (const raw of String(diff).split('\n')) {
|
||||
if (raw.startsWith('+++ ')) {
|
||||
const path = raw.slice(4).replace(/^b\//, '');
|
||||
current = path === '/dev/null' ? null : path;
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith('@@')) {
|
||||
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
||||
next = match ? Number(match[1]) : 0;
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith('---') || raw.startsWith('diff --git')) continue;
|
||||
if (raw.startsWith('-')) {
|
||||
const text = raw.slice(1);
|
||||
removed.set(text, (removed.get(text) || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
if (!current || !next) continue;
|
||||
if (raw.startsWith('+')) { added.push({ path: current, line: next, text: raw.slice(1) }); next += 1; continue; }
|
||||
if (raw.startsWith('\\')) continue;
|
||||
next += 1;
|
||||
}
|
||||
const moved = new Map();
|
||||
for (const item of added) {
|
||||
const budget = removed.get(item.text) || 0;
|
||||
if (!budget) continue;
|
||||
removed.set(item.text, budget - 1);
|
||||
if (!moved.has(item.path)) moved.set(item.path, new Set());
|
||||
moved.get(item.path).add(item.line);
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
const isProductTypeScript = (path) => /^src\/.*\.ts$/.test(path);
|
||||
|
||||
/**
|
||||
@@ -182,19 +236,25 @@ function main(argv) {
|
||||
}
|
||||
|
||||
const added = addedLinesByFile(diff);
|
||||
const movedByFile = movedLinesByFile(diff);
|
||||
const files = [];
|
||||
for (const [path, addedLines] of added) {
|
||||
if (!isProductTypeScript(path) || !addedLines.size) continue;
|
||||
const full = resolve(ROOT, path);
|
||||
// Файл мог быть удалён в этом же диапазоне — судить нечего.
|
||||
if (!existsSync(full)) continue;
|
||||
files.push({ path, text: readFileSync(full, 'utf8'), addedLines });
|
||||
files.push({
|
||||
path, text: readFileSync(full, 'utf8'), addedLines,
|
||||
movedLines: movedByFile.get(path) || new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
const violations = findNewAnyViolations({ files });
|
||||
const scanned = files.reduce((sum, file) => sum + file.addedLines.size, 0);
|
||||
const moved = files.reduce((sum, file) => sum + file.movedLines.size, 0);
|
||||
console.log(`Проверено добавленных строк в src/**/*.ts: ${scanned}`
|
||||
+ ` в ${files.length} файл(ах).`);
|
||||
+ ` в ${files.length} файл(ах).`
|
||||
+ (moved ? ` Из них перенесены дословно из других файлов диапазона: ${moved}.` : ''));
|
||||
if (!violations.length) {
|
||||
console.log('Новых any нет.');
|
||||
return 0;
|
||||
|
||||
@@ -2,7 +2,8 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
addedLinesByFile, anyKeywordLines, blameLine, findNewAnyViolations, formatViolation, parseAnyOk,
|
||||
addedLinesByFile, anyKeywordLines, blameLine, findNewAnyViolations, formatViolation,
|
||||
movedLinesByFile, parseAnyOk,
|
||||
} from '../scripts/no-new-any.mjs';
|
||||
|
||||
// #342. Цель гейта — не перетипизировать монолит, а не давать долгу расти. В
|
||||
@@ -131,3 +132,66 @@ test('недоступный blame не выдумывает источник и
|
||||
' src/a.ts:1 — нет обоснования',
|
||||
);
|
||||
});
|
||||
|
||||
// #592. Извлечение подсистемы — то, чем по замыслу #342 и снимается долг, —
|
||||
// выглядит для диффа как тысяча добавленных строк. Судить по ним «новый код»
|
||||
// значит требовать типизации ровно там, где ничего не изменилось, и заодно
|
||||
// ломать доказательство переноса: тело обязано совпадать побайтово.
|
||||
test('#592 строка, перенесённая дословно, новым кодом не считается', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/big.ts b/src/big.ts',
|
||||
'--- a/src/big.ts',
|
||||
'+++ b/src/big.ts',
|
||||
'@@ -10,1 +10,0 @@',
|
||||
'- const handler = (e: any) => e;',
|
||||
'diff --git a/src/editors/part.ts b/src/editors/part.ts',
|
||||
'--- /dev/null',
|
||||
'+++ b/src/editors/part.ts',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+ const handler = (e: any) => e;',
|
||||
'+ const fresh = (e: any) => e;',
|
||||
].join('\n');
|
||||
const moved = movedLinesByFile(diff);
|
||||
assert.deepEqual([...(moved.get('src/editors/part.ts') || [])], [1],
|
||||
'перенесена первая строка; вторая такого удаления не имеет');
|
||||
|
||||
const text = ' const handler = (e: any) => e;\n const fresh = (e: any) => e;\n';
|
||||
const violations = findNewAnyViolations({ files: [{
|
||||
path: 'src/editors/part.ts', text,
|
||||
addedLines: new Set([1, 2]),
|
||||
movedLines: moved.get('src/editors/part.ts'),
|
||||
}] });
|
||||
assert.equal(violations.length, 1, 'новый any по-прежнему находка');
|
||||
assert.equal(violations[0].line, 2);
|
||||
});
|
||||
|
||||
test('#592 бюджет переноса ведётся мультимножеством, а не признаком', () => {
|
||||
const diff = [
|
||||
'--- a/src/big.ts',
|
||||
'+++ b/src/big.ts',
|
||||
'@@ -10,1 +10,0 @@',
|
||||
'- const cast = v as any;',
|
||||
'--- /dev/null',
|
||||
'+++ b/src/editors/part.ts',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+ const cast = v as any;',
|
||||
'+ const cast = v as any;',
|
||||
].join('\n');
|
||||
const moved = movedLinesByFile(diff);
|
||||
assert.deepEqual([...(moved.get('src/editors/part.ts') || [])], [1],
|
||||
'одно удаление покрывает одно добавление, второе остаётся новым');
|
||||
});
|
||||
|
||||
test('#592 перенос с изменённым отступом переносом не считается', () => {
|
||||
const diff = [
|
||||
'--- a/src/big.ts',
|
||||
'+++ b/src/big.ts',
|
||||
'@@ -10,1 +10,0 @@',
|
||||
'- const cast = v as any;',
|
||||
'--- /dev/null',
|
||||
'+++ b/src/editors/part.ts',
|
||||
'@@ -0,0 +1,1 @@',
|
||||
'+ const cast = v as any;',
|
||||
].join('\n');
|
||||
assert.equal(movedLinesByFile(diff).size, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user