mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 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
397 lines
19 KiB
JavaScript
397 lines
19 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
|
||
* 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)));
|
||
}
|