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:
Claude
2026-08-28 16:02:17 +03:00
parent a7f0facf9a
commit 583637313f
7 changed files with 419 additions and 3 deletions
+19 -3
View File
@@ -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'/);
});