Files
houseplan-card/scripts/no-new-any.mjs
T
Claude 696f5a789f docs(hygiene): сократить вход агента, у правила — один дом (#680)
Волна 3 эпика #674. AGENTS.md 650 → 187 строк: карта пакета, маршрут чтения,
правило №1, классы и треки одной строкой со ссылками, трейлеры, рабочие
деревья, хендофф и ожидание вердикта; пересказы PROCESS.md — ссылками на
разделы. Неверный список «Gate jobs» снят (списки jobs не копируются в прозу,
шапка PROCESS.md). Правила, жившие только в AGENTS, получили дом: жёлтый
вердикт при выполненных AC — PROCESS §2.7; свежесть бандла, съёмка только в
Linux (#455, HP_ALLOW_FOREIGN_CAPTURE) и смоки из AC до S7 (#151) —
TESTING.md; причуда демо-стенда и среда-зависимый smoke_opening_measure —
DEVELOPMENT › Smoke tests; отказ публикации без `Release:` и при несвежем
отпечатке бандла, отмена Validate новым пушем, кандидат беты не
promotion-only, fail-closed реестра Labs — DEVELOPMENT; предупреждение и
ошибка свежести скриншотов — CONTRIBUTING.

PROCESS.md: §13 (внедрение с открытым ⏳), §14 (блок со ссылкой на
несуществующий docs/PROCESS.md) и §7.3 (история) удалены. Ссылки «§7.2» на
правило полного разбора после ребейза ведут в §2.10, на сверку SHA перед
выводом — в §2.7; то же в сообщениях scripts/branch-state.mjs,
merge-candidate.mjs, review-doc-guard.mjs, pre-push-gate.mjs, в промпте
_process.yml и TESTING.md. Число `any` в прозе → `node scripts/no-new-any.mjs
--total` (новый режим, юнит-тест; было «1034 в 49 файлах», сейчас 862 в 52),
дата-число замороженного списка якорей монолита снято. Устаревшая команда
пересъёмки скриншотов в §8 заменена ссылкой на действующий путь.

STATUS.md 113 → 61 строка: сгенерированный снимок, текущий цикл и девять
строк решений; Workflow, CI, Toolchain, Tests, Scope, open items и политика
документации — ссылками (PROCESS §2.6, DEVELOPMENT › Release, TESTING);
локали en/ru/de/fr; закрытые «coverage, mypy strict» сняты.

DEVELOPMENT.md: file-sync и «Reproducible scripts» (прототип) удалены;
раздел Release — единственный дом релизной механики: введение, правила
тела стабильного релиза (#328, release:notes), шаг continuity:screencast,
источники версии по release-contract. CONTRIBUTING: ссылка на Release вместо
пересказа, замеры клона без чисел. TESTING: any-гейт — ссылкой на PROCESS §8.

entry-cost: автор 11 125 → 5 407 слов, ревьюер 8 464 → 4 285.

Issue: #680
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:33:03 +03:00

397 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
* node scripts/no-new-any.mjs --total # весь долг src/** (#680)
*
* Зачем гейт, а не разовая типизация. Явного `any` в `src/**` — сотни
* вхождений (точное число печатает `--total`); перетипизировать это одним
* заходом значит месяц риска ради нуля пользовательской ценности. Долг
* снимается при плановом извлечении подсистем (#425, прежний #34). Задача
* гейта одна: не давать долгу расти.
*
* Практический вред уже случался: несоответствие форм (`d.source.kind` против
* строкового `source`) компилятор не поймал, потому что путь был через `any`, и
* это всплыло только в браузерном смоке.
*
* Почему через компилятор, а не регуляркой. Регулярка по строке даёт ложные
* срабатывания там, где слово `any` живёт в прозе: внутри шаблонной строки
* `html` или в комментарии. Здесь текст файла разбирается настоящим парсером
* TypeScript, и узел `AnyKeyword` — это ровно тип `any` и ничто другое.
* Комментарии, строковые литералы и идентификаторы вида `company`, `anyOf`,
* `manyRooms` узлами этого вида не являются, поэтому false positive невозможен
* не по старанию, а по построению.
*
* Исключение — на той же строке: `// any-ok: <причина>`. Причина обязана быть
* конкретной: голый маркер, пустая или шаблонная причина («todo», «потом»,
* «надо») гейт не проходят. Формулировка вида «внешний контракт HA не
* типизирован» проходит.
*
* Второе исключение — перенос (#592). Код, который в этом же диапазоне удалён
* из одного файла и дословно добавлен в другой, новым не является:
* ответственность за его типы не менялась, и долг в `src/**` не вырос. Без
* этого послабления любое извлечение подсистемы — то самое, чем долг и
* снимается по замыслу #342, — краснит гейт ровно за то, что ничего не
* изменило.
*
* Послабление даётся БЛОКУ, а не строке (ревью кода #592, M1). Первая редакция
* сопоставляла одиночные строки по всему диффу, и этого хватало для обхода:
* несвязанная уборка удаляет где-то строку с `any`, а новый код добавляет свою,
* текстуально совпадающую, — и гейт молчит. Совпадение тут не экзотика: в этой
* базе 887 явных `any`, и типовые однострочники вроде
* `<span>${...(k as any)}</span>` повторяются буквально (две таких строки
* встретились в самом коммите переноса).
*
* Поэтому перенесённым признаётся только непрерывный кусок длиной не меньше
* MOVED_BLOCK_MIN строк, встречающийся подряд и целиком среди удалённых строк
* ОДНОГО файла. Случайно совпасть пятью строками подряд в двух местах одного
* диффа практически невозможно, а настоящее извлечение подсистемы состоит из
* таких кусков по определению. Каждый удалённый кусок оплачивает ровно одно
* добавление: повторная вставка того же блока остаётся новым кодом.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, relative, 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) {
return parseExemption(lineText, 'any-ok');
}
/**
* То же правило для любого маркера исключения вида `// <marker>: <причина>`.
* Общее с `no-new-private-writes` (#629): требования к причине не должны
* расходиться между гейтами.
*/
export function parseExemption(lineText, marker) {
const escaped = String(marker).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`\\/\\/\\s*${escaped}\\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;
}
/**
* Весь существующий долг (#680): число узлов `AnyKeyword` и файлов с ними по
* продуктовому TypeScript. Документы называют эту команду вместо числа — число в
* прозе отставало от дерева с первой недели.
*/
export function totalAny(files) {
let occurrences = 0;
let withAny = 0;
for (const file of files) {
let count = 0;
for (const perLine of anyKeywordLines(file.path, file.text).values()) count += perLine;
occurrences += count;
if (count) withAny += 1;
}
return { occurrences, files: withAny };
}
function productTypeScriptFiles(dir = join(ROOT, 'src')) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) { out.push(...productTypeScriptFiles(full)); continue; }
const path = relative(ROOT, full).split('\\').join('/');
if (isProductTypeScript(path)) out.push({ path, text: readFileSync(full, 'utf8') });
}
return out;
}
/**
* Нарушения: `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;
if (file.movedLines?.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;
}
/** Минимальная длина непрерывного куска, который считается переносом. */
export const MOVED_BLOCK_MIN = 5;
/**
* Строки, которые приехали в файл переносом непрерывного куска (#592).
*
* Возвращает по файлу номера таких строк. Сравнение точное, без обрезки
* пробелов: перенос с изменённым отступом — уже правка, и судить её гейт обязан.
*/
export function movedLinesByFile(diff, { minBlock = MOVED_BLOCK_MIN, details } = {}) {
const removedByFile = new Map();
const addedByFile = new Map();
let target = null;
let sourcePath = null;
let next = 0;
for (const raw of String(diff).split('\n')) {
if (raw.startsWith('--- ')) {
const path = raw.slice(4).replace(/^a\//, '');
sourcePath = path === '/dev/null' ? null : path;
continue;
}
if (raw.startsWith('+++ ')) {
const path = raw.slice(4).replace(/^b\//, '');
target = 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('diff --git')) continue;
if (raw.startsWith('-')) {
const key = sourcePath || target || '';
if (!removedByFile.has(key)) removedByFile.set(key, []);
removedByFile.get(key).push(raw.slice(1));
continue;
}
if (!target || !next) continue;
if (raw.startsWith('+')) {
if (!addedByFile.has(target)) addedByFile.set(target, []);
addedByFile.get(target).push({ line: next, text: raw.slice(1) });
next += 1;
continue;
}
if (raw.startsWith('\\')) continue;
next += 1;
}
// Позиции удалённых строк по тексту — чтобы искать начало куска за один шаг.
const index = new Map();
for (const [path, lines] of removedByFile) {
lines.forEach((text, at) => {
if (!index.has(text)) index.set(text, []);
index.get(text).push({ path, at });
});
}
const spent = new Map();
const isSpent = (path, at) => spent.get(path)?.has(at) === true;
const spend = (path, from, length) => {
if (!spent.has(path)) spent.set(path, new Set());
for (let step = 0; step < length; step += 1) spent.get(path).add(from + step);
};
const moved = new Map();
for (const [path, added] of addedByFile) {
// Куски считаются по непрерывным номерам строк: разрыв — конец куска.
const runs = [];
for (const item of added) {
const last = runs[runs.length - 1];
if (last && last[last.length - 1].line + 1 === item.line) last.push(item);
else runs.push([item]);
}
for (const run of runs) {
let at = 0;
while (at < run.length) {
let best = null;
for (const start of index.get(run[at].text) || []) {
if (isSpent(start.path, start.at)) continue;
const source = removedByFile.get(start.path) || [];
let length = 0;
while (at + length < run.length
&& start.at + length < source.length
&& !isSpent(start.path, start.at + length)
&& source[start.at + length] === run[at + length].text) length += 1;
if (length >= minBlock && (!best || length > best.length)) {
best = { path: start.path, at: start.at, length };
}
}
if (!best) { at += 1; continue; }
spend(best.path, best.at, best.length);
if (!moved.has(path)) moved.set(path, new Set());
for (let step = 0; step < best.length; step += 1) moved.get(path).add(run[at + step].line);
at += best.length;
}
}
}
// Удалённые строки и те из них, что ушли на оплату переноса, — для гейтов,
// которым нужен зачёт правок без двойного счёта (#629).
if (details) {
details.removedByFile = removedByFile;
details.spent = spent;
}
return moved;
}
const isProductTypeScript = (path) => /^src\/.*\.ts$/.test(path);
/**
* Коммит, добавивший строку (issue #388).
*
* Диапазон гейта теперь считается от последнего ДОКАЗАННО зелёного предка, а не
* от головы предыдущего пуша, — и это значит, что находка может относиться к
* чужому коммиту, чей прогон был отменён. Без имени источника такое сообщение
* обвиняет того, кто пушнул следующим: ровно то, что пришлось чинить в #386 для
* golden.
*/
export function blameLine(path, line, runner = defaultBlame) {
const out = runner(path, line);
const match = typeof out === 'string' ? out.match(/^([0-9a-f]{7,40})\s/) : null;
return match ? match[1].slice(0, 8) : '';
}
const defaultBlame = (path, line) => {
const run = spawnSync('git', [
'-C', ROOT, 'blame', '-L', `${line},${line}`, '--porcelain', 'HEAD', '--', path,
], { encoding: 'utf8' });
return run.status === 0 ? run.stdout : '';
};
/** Строка отчёта о находке. Источник печатается, только если он известен. */
export function formatViolation(violation, source) {
const where = source ? ` (добавил ${source})` : '';
return ` ${violation.path}:${violation.line}${where} — ${violation.reason}`;
}
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;
};
if (argv.includes('--total')) {
const total = totalAny(productTypeScriptFiles());
console.log(`Явный any в src/**/*.ts: ${total.occurrences} вхождений в ${total.files} файл(ах).`);
return 0;
}
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 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,
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} файл(ах).`
+ (moved ? ` Из них перенесены дословно из других файлов диапазона: ${moved}.` : ''));
if (!violations.length) {
console.log('Новых any нет.');
return 0;
}
console.error(`\nНовый явный any: ${violations.length}\n`);
for (const violation of violations) {
console.error(formatViolation(violation, blameLine(violation.path, violation.line)));
console.error(` ${violation.text}`);
}
console.error('\nЛибо типизируйте, либо обоснуйте на той же строке:');
console.error(' // any-ok: <конкретная причина, почему тип недоступен>');
console.error('Существующий долг снимается при извлечении подсистем (#34, #342),');
console.error('а не разовой заменой; сколько его — node scripts/no-new-any.mjs --total.');
return 1;
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
process.exit(main(process.argv.slice(2)));
}