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:
Claude
2026-09-18 19:59:11 +03:00
parent b76f3e5756
commit 61c74a7012
3 changed files with 139 additions and 3 deletions
+12
View File
@@ -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
View File
@@ -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;
+65 -1
View File
@@ -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);
});