Files
houseplan-card/scripts/no-new-any.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

189 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Новый код не добавляет `any` (#342).
*
* node scripts/no-new-any.mjs # origin/dev...HEAD
* node scripts/no-new-any.mjs --base origin/dev --head HEAD
* node scripts/no-new-any.mjs --diff patch.diff # или `-` для stdin
*
* Зачем гейт, а не разовая типизация. В `src/**` сейчас 1034 вхождения явного
* `any` в 49 файлах — перетипизировать это одним заходом значит месяц риска ради
* нуля пользовательской ценности. Долг снимается при плановом извлечении
* подсистем (#34). Задача гейта одна: не давать долгу расти.
*
* Практический вред уже случался: несоответствие форм (`d.source.kind` против
* строкового `source`) компилятор не поймал, потому что путь был через `any`, и
* это всплыло только в браузерном смоке.
*
* Почему через компилятор, а не регуляркой. Регулярка по строке даёт ложные
* срабатывания там, где слово `any` живёт в прозе: внутри шаблонной строки
* `html` или в комментарии. Здесь текст файла разбирается настоящим парсером
* TypeScript, и узел `AnyKeyword` — это ровно тип `any` и ничто другое.
* Комментарии, строковые литералы и идентификаторы вида `company`, `anyOf`,
* `manyRooms` узлами этого вида не являются, поэтому false positive невозможен
* не по старанию, а по построению.
*
* Исключение — на той же строке: `// any-ok: <причина>`. Причина обязана быть
* конкретной: голый маркер, пустая или шаблонная причина («todo», «потом»,
* «надо») гейт не проходят. Формулировка вида «внешний контракт HA не
* типизирован» проходит.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** Причины, которые ничего не объясняют: маркер вместо обоснования. */
const EMPTY_REASONS = [
'todo', 'fixme', 'later', 'wip', 'temporary', 'temp', 'hack', 'refactor',
'потом', 'надо', 'позже', 'временно', 'разобраться', 'исправить',
];
const MIN_REASON_LENGTH = 12;
/**
* Разобрать исключение на строке. Возвращает `null`, если маркера нет.
* `ok: false` означает, что маркер есть, но обоснования в нём нет.
*/
export function parseAnyOk(lineText) {
const match = /\/\/\s*any-ok\b\s*:?\s*(.*)$/.exec(String(lineText ?? ''));
if (!match) return null;
const reason = match[1].trim().replace(/\s+/g, ' ');
const bare = reason.replace(/[.…!?—–-]+$/g, '').trim().toLowerCase();
const ok = reason.length >= MIN_REASON_LENGTH && !EMPTY_REASONS.includes(bare);
return { reason, ok };
}
/** Номера строк, где TypeScript видит тип `any`. */
export function anyKeywordLines(path, text) {
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
const lines = new Map();
const visit = (node) => {
if (node.kind === ts.SyntaxKind.AnyKeyword) {
const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
lines.set(line + 1, (lines.get(line + 1) || 0) + 1);
}
ts.forEachChild(node, visit);
};
visit(source);
return lines;
}
/**
* Нарушения: `any` на строке, которую диапазон объявил добавленной.
*
* Изменённая строка в диффе выглядит добавленной, и это намеренно: правка
* строки со старым `any` — новая ответственность, её либо типизируют, либо
* обосновывают исключением.
*/
export function findNewAnyViolations({ files }) {
const violations = [];
for (const file of files) {
const textLines = String(file.text).split('\n');
for (const [line, count] of anyKeywordLines(file.path, file.text)) {
if (!file.addedLines.has(line)) continue;
const lineText = textLines[line - 1] ?? '';
const exemption = parseAnyOk(lineText);
if (exemption?.ok) continue;
violations.push({
path: file.path,
line,
count,
text: lineText.trim(),
reason: exemption
? `маркер any-ok без конкретной причины: «${exemption.reason || '(пусто)'}»`
: 'явный any на добавленной строке',
});
}
}
return violations.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
}
/** Добавленные строки на файл из унифицированного диффа с нулевым контекстом. */
export function addedLinesByFile(diff) {
const files = new Map();
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;
if (current && !files.has(current)) files.set(current, new Set());
continue;
}
if (raw.startsWith('@@')) {
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
next = match ? Number(match[1]) : 0;
continue;
}
if (!current || !next) continue;
if (raw.startsWith('+')) { files.get(current).add(next); next += 1; continue; }
if (raw.startsWith('-') || raw.startsWith('\\')) continue;
next += 1;
}
return files;
}
const isProductTypeScript = (path) => /^src\/.*\.ts$/.test(path);
function main(argv) {
const value = (name, fallback) => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
if (found) return found.slice(name.length + 3);
const index = argv.indexOf(`--${name}`);
return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--')
? argv[index + 1] : fallback;
};
const diffArg = value('diff');
let diff;
if (diffArg) {
diff = diffArg === '-' ? readFileSync(0, 'utf8') : readFileSync(diffArg, 'utf8');
} else {
const base = value('base', 'origin/dev');
const head = value('head', 'HEAD');
const run = spawnSync('git', [
'-C', ROOT, 'diff', '--unified=0', '--no-color', `${base}...${head}`, '--', 'src',
], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
if (run.status !== 0) {
console.error(`git diff ${base}...${head} не удался:\n${run.stderr}`);
return 2;
}
diff = run.stdout;
}
const added = addedLinesByFile(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 });
}
const violations = findNewAnyViolations({ files });
const scanned = files.reduce((sum, file) => sum + file.addedLines.size, 0);
console.log(`Проверено добавленных строк в src/**/*.ts: ${scanned}`
+ ` в ${files.length} файл(ах).`);
if (!violations.length) {
console.log('Новых any нет.');
return 0;
}
console.error(`\nНовый явный any: ${violations.length}\n`);
for (const violation of violations) {
console.error(` ${violation.path}:${violation.line} — ${violation.reason}`);
console.error(` ${violation.text}`);
}
console.error('\nЛибо типизируйте, либо обоснуйте на той же строке:');
console.error(' // any-ok: <конкретная причина, почему тип недоступен>');
console.error('Существующий долг снимается при извлечении подсистем (#34, #342),');
console.error('а не разовой заменой: в src/** его 1034 вхождения в 49 файлах.');
return 1;
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
process.exit(main(process.argv.slice(2)));
}