Files
houseplan-card/test/no-new-any.test.mjs
T
Claude 583637313f test: гейт «новый код не добавляет any»
В src/** сейчас 1034 вхождения явного any в 49 файлах — больше, чем называл
аудит (330), потому что монолит с тех пор разделился и его обвязка уехала в
houseplan-editor-runtime.ts. Разовая замена такого объёма — месяц риска ради
нуля пользовательской ценности, поэтому долг снимается при плановом извлечении
подсистем (#34). Задача гейта одна: не давать долгу расти.

Судятся только добавленные строки диапазона. Изменённая строка со старым any
выглядит в диффе добавленной, и это намеренно: тронул — либо типизируй, либо
обоснуй на той же строке `// any-ok: <причина>`. Голый маркер, пустая причина и
шаблоны вроде todo, hack, потом не проходят.

Ложных срабатываний нет по построению, а не по старанию: текст разбирается
парсером TypeScript, и нарушением считается узел AnyKeyword. Регулярка по строке
ловила бы слово any в прозе внутри шаблона html и в комментариях; здесь
комментарии, строковые литералы, многострочные шаблоны и идентификаторы
company, anyOf, manyRooms узлами такого вида не являются вовсе.

Проверено исполнением на настоящем дереве, а не только юнитами: пробные коммиты
в src/wall-thickness.ts показали, что добавленный any падает с файлом и строкой,
типизированная строка в файле с 122 старыми any проходит, any-ok с конкретной
причиной проходит, а голый и «todo» — нет, и что any в прозе, строке и
идентификаторах не даёт ни одного срабатывания.

В job frontend checkout получил полную историю без блобов: diff-aware проверке
нужен диапазон, а содержимое старых ревизий — нет.

Заодно закрыта ловушка в test/validate-workflow.test.mjs: имя job искалось через
indexOf('  frontend:'), а эта строка встречается внутри `      frontend: ${{ ...
}}` в outputs job changes, поэтому срез уходил не туда. Теперь имя ищется с
начала строки.

Четыре мутанта проверены руками, два добавлены в реестр: гейт, судящий все
строки, и гейт, принимающий голый маркер.

Issue: #342
User-Visible: no
2026-08-28 16:02:17 +03:00

109 lines
5.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
addedLinesByFile, anyKeywordLines, findNewAnyViolations, parseAnyOk,
} from '../scripts/no-new-any.mjs';
// #342. Цель гейта — не перетипизировать монолит, а не давать долгу расти. В
// src/** сейчас 1034 вхождения явного any в 49 файлах; разовая замена — месяц
// риска ради нуля пользовательской ценности, поэтому долг снимается при
// извлечении подсистем (#34), а гейт держит приращение на нуле.
const file = (text, addedLines) => ({
path: 'src/probe.ts', text, addedLines: new Set(addedLines),
});
test('добавленный явный any во всех формах — нарушение (#342 AC1)', () => {
const text = [
'export const a = (v: any): number => 1;',
'export const b = (v: unknown) => v as any;',
'export const c: any[] = [];',
'export const d: Record<string, any> = {};',
'export const e = <any>null;',
].join('\n');
const violations = findNewAnyViolations({ files: [file(text, [1, 2, 3, 4, 5])] });
assert.deepEqual(violations.map((item) => item.line), [1, 2, 3, 4, 5]);
});
test('старый any на нетронутой строке гейт не блокирует (#342 AC2)', () => {
// Ровно то свойство, из-за которого гейт вообще применим: существующий долг
// допустим, пока подсистему не извлекли.
const text = 'export const old = (v: any) => v;\nexport const fresh = (v: number) => v;';
assert.deepEqual(findNewAnyViolations({ files: [file(text, [2])] }), []);
});
test('правка строки со старым any — новая ответственность (#342 AC2)', () => {
// Изменённая строка в диффе выглядит добавленной, и это намеренно: тронул —
// либо типизируй, либо обоснуй.
const text = 'export const old = (v: any) => v;';
assert.equal(findNewAnyViolations({ files: [file(text, [1])] }).length, 1);
});
test('any-ok проходит только с конкретной причиной (#342 AC3)', () => {
const lines = [
'const a = (v: any) => v; // any-ok',
'const b = (v: any) => v; // any-ok: todo',
'const c = (v: any) => v; // any-ok: потом',
'const d = (v: any) => v; // any-ok: форма события HA не типизирована в @types',
].join('\n');
const violations = findNewAnyViolations({ files: [file(lines, [1, 2, 3, 4])] });
assert.deepEqual(violations.map((item) => item.line), [1, 2, 3]);
for (const violation of violations) assert.match(violation.reason, /без конкретной причины/);
});
test('разбор маркера отличает отсутствие, пустоту и обоснование (#342 AC3)', () => {
assert.equal(parseAnyOk('const a = 1;'), null);
assert.equal(parseAnyOk('// any-ok').ok, false);
assert.equal(parseAnyOk('// any-ok: hack').ok, false);
assert.equal(parseAnyOk('// any-ok: внешний контракт HA не типизирован').ok, true);
});
test('слово any в прозе, строках и идентификаторах не ловится (#342 AC4)', () => {
// Регулярка по строке здесь давала бы ложные срабатывания, поэтому текст
// разбирается парсером TypeScript: узел AnyKeyword — это тип any и ничто
// другое, а комментарии и литералы узлами такого вида не бывают.
const text = [
'/** Choose any room: prose mentions any twice, any. */',
"export const companyName = 'any company, any size';",
'export const anyOfThem = (manyRooms: number): string => `pick any of ${manyRooms}`;',
'export const tpl = `',
' multi-line template mentioning any room',
'`;',
].join('\n');
assert.deepEqual(findNewAnyViolations({ files: [file(text, [1, 2, 3, 4, 5, 6])] }), []);
assert.equal(anyKeywordLines('src/probe.ts', text).size, 0);
});
test('добавленные строки читаются из диффа с нулевым контекстом (#342 AC5)', () => {
const diff = [
'diff --git a/src/one.ts b/src/one.ts',
'--- a/src/one.ts',
'+++ b/src/one.ts',
'@@ -10,0 +11,2 @@',
'+const a = 1;',
'+const b = 2;',
'@@ -20,1 +22,1 @@',
'-const old = 3;',
'+const neu = 3;',
'diff --git a/src/two.ts b/src/two.ts',
'--- /dev/null',
'+++ b/src/two.ts',
'@@ -0,0 +1,1 @@',
'+const c = 4;',
].join('\n');
const added = addedLinesByFile(diff);
assert.deepEqual([...added.get('src/one.ts')].sort((a, b) => a - b), [11, 12, 22]);
assert.deepEqual([...added.get('src/two.ts')], [1]);
});
test('удалённый файл не даёт нарушений: судить нечего (#342)', () => {
const diff = [
'--- a/src/gone.ts',
'+++ /dev/null',
'@@ -1,1 +0,0 @@',
'-const a: any = 1;',
].join('\n');
assert.equal(addedLinesByFile(diff).size, 0);
});