mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
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
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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);
|
||||
});
|
||||
@@ -66,7 +66,9 @@ test('бандл собирается один раз и приезжает бр
|
||||
|
||||
test('предполётные проверки не прячут друг друга (#336)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const preflight = workflow.slice(workflow.indexOf(' preflight:'), workflow.indexOf(' changes:'));
|
||||
const preflight = workflow.slice(
|
||||
workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'),
|
||||
);
|
||||
for (const id of ['docs', 'workflow_sync', 'provenance', 'process_gate']) {
|
||||
assert.ok(preflight.includes(`id: ${id}`), `нет шага ${id}`);
|
||||
assert.ok(preflight.includes(`steps.${id}.outcome`), `вердикт не читает ${id}`);
|
||||
@@ -82,8 +84,10 @@ test('джобы с полной историей качают её без бл
|
||||
// Полный клон — 215 МБ .git, blobless — 26 МБ, история и теги в обоих полные
|
||||
// (замер в #345). Обе эти job читают сообщения коммитов и ИМЕНА изменённых
|
||||
// файлов; содержимое старых ревизий им не нужно ни на одном шаге.
|
||||
for (const job of ['preflight', 'changes']) {
|
||||
const start = workflow.indexOf(` ${job}:`);
|
||||
for (const job of ['preflight', 'changes', 'frontend']) {
|
||||
// ' frontend:' встречается внутри ` frontend: ${{ ... }}` в outputs
|
||||
// job `changes`, поэтому имя job ищется только с начала строки.
|
||||
const start = workflow.indexOf(`\n ${job}:\n`);
|
||||
assert.ok(start > 0, `нет job ${job}`);
|
||||
const chunk = workflow.slice(start, start + 1400);
|
||||
assert.match(chunk, /fetch-depth: 0, filter: 'blob:none'/,
|
||||
@@ -95,3 +99,15 @@ test('джобы с полной историей качают её без бл
|
||||
assert.equal(workflow.includes('fetch-depth: 1'), false,
|
||||
'shallow-клон ломает merge-base: диапазоны и процессный гейт перестают работать');
|
||||
});
|
||||
|
||||
test('гейт «новый код не добавляет any» вызывается в frontend (#342)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const frontend = workflow.slice(
|
||||
workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n smoke:\n'),
|
||||
);
|
||||
assert.match(frontend, /node scripts\/no-new-any\.mjs --base/,
|
||||
'гейт обязан вызываться, иначе долг типизации снова начнёт расти');
|
||||
// Гейт diff-aware, поэтому без истории он бессмысленен: на глубине 1
|
||||
// merge-base не считается и диапазон выродится в «всё».
|
||||
assert.match(frontend, /fetch-depth: 0, filter: 'blob:none'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user