Files
houseplan-card/scripts/unused-locals-gate.mjs
T
Claude 47469bab22 Монолит: мёртвый код снят по noUnusedLocals, связность измеряется шестью числами и гейтом (#624)
Карточка и редакторский рантайм держали ≈380 неиспользуемых импортов, 56
мёртвых объявлений и дублей типов (warm-boot, LS_*, GLOW_*, debounce,
navigate, lruRead — копии карточки в рантайме) и 112 приватных членов
карточки, которых не читал никто — делегаты `_editorRuntimeOrThrow()._x()`,
оставшиеся от выноса #425, и аксессоры glow-состояния. Всё это снято; в 9
других файлах — по одиночной ошибке. Делегаты и поля, которых касаются
браузерные смоки (`card._x(...)`), оставлены и посчитаны отдельно.

Гейт `npm run lint:unused` (scripts/unused-locals-gate.mjs, в gate:small и
Validate после сборки): `tsc --noUnusedLocals` чист, кроме приватных членов
карточки из порта HouseplanEditorHostPort / `host.` (portPrivates) и членов,
которых зовёт харнесс (harnessPrivates); храповик по шести числам
scripts/monolith-metrics.mjs против scripts/monolith-baseline.json —
delegates 260→159, portMembers 350, hostRefs 4948, portPrivates 96,
harnessPrivates 107, bundleBytes 2 510 141→2 500 387. `npm run inventory`
печатает те же числа. Заморозка 54 тестов, читающих монолит как текст
(test/monolith-text-anchors.test.mjs); PROCESS.md §2.7 — правило.

Логический исходник для контрактных тестов (test/houseplan-source.mjs)
дописывает члены рантайма без делегата в карточке — контракт продукта не
зависит от наличия заглушки. Потолки ядер и initial gzip опущены на выигрыш
(292 000 → 290 400). Бандл пересобран, три копии синхронны.

Issue: #624
User-Visible: no
2026-09-23 21:09:44 +03:00

107 lines
5.9 KiB
JavaScript
Raw Permalink 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
/**
* Гейт «мёртвый код и связность монолита» (#624): `npm run lint:unused`.
*
* node scripts/unused-locals-gate.mjs # проверить против базы
* node scripts/unused-locals-gate.mjs --update # записать текущие числа в базу
* node scripts/unused-locals-gate.mjs --json # числа и разбор в JSON
*
* Две проверки, оба ответа — в одном прогоне:
*
* 1. `tsc --noUnusedLocals` по всему проекту чист, кроме приватных членов
* карточки, которых карточка не читает, но читает рантайм через порт
* (`portPrivates`) или зовут браузерные смоки (`harnessPrivates`). Любая
* другая диагностика — неиспользуемый импорт, мёртвая константа, локальная
* переменная, приватный член вне порта и харнесса — красный: это код,
* который компилятор доказал мёртвым.
*
* 2. Храповик: шесть чисел `scripts/monolith-metrics.mjs` не растут
* относительно `scripts/monolith-baseline.json` (`bundleBytes` — с полосой
* ±2 000 Б, как gzip-потолок #438; остальные — точно). Снижение — не ошибка, но
* база обязана быть опущена тем же коммитом (`--update`): незафиксированный
* выигрыш монолит отыграет обратно первой же правкой. Рост допускается
* только с явной записью в issue задачи и правкой базы в том же коммите —
* гейт печатает, какое число и на сколько.
*
* `bundleBytes` требует собранного `dist/`: гейт стоит после `npm run build`
* (в `gate:small` и в job `frontend` Validate). Без сборки число не судится,
* и гейт говорит об этом явно, а не молчит зелёным.
*/
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import {
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline,
} from './monolith-metrics.mjs';
/**
* Чистое решение гейта: код выхода и строки отчёта по замеру и базе.
* `violations` — диагностики вне разрешённых; `grown` — выросшие числа.
*/
export function decide({ metrics, violations, baseline }) {
const lines = [];
let fail = false;
lines.push(`monolith: ${formatMetrics(metrics)}`);
if (violations.length) {
fail = true;
lines.push(`FAIL мёртвый код — ${violations.length} диагностик(и) noUnusedLocals вне порта и харнесса:`);
for (const v of violations.slice(0, 40)) lines.push(` ${v.file}:${v.line} TS${v.code} ${v.message}`);
if (violations.length > 40) lines.push(` … ещё ${violations.length - 40}`);
} else {
lines.push('ok мёртвого кода нет: единственные непрочитанные члены — порт и харнесс');
}
if (!baseline) {
fail = true;
lines.push(`FAIL базы ${BASELINE_FILE} нет — создать: node scripts/unused-locals-gate.mjs --update`);
return { fail, lines, grown: [], shrunk: [] };
}
const { grown, shrunk } = compareWithBaseline(metrics, baseline);
if (grown.length) {
fail = true;
for (const g of grown) {
lines.push(`FAIL связность выросла: ${g.name} ${g.base ?? 'нет в базе'} → ${g.now}`
+ ' — вернуть или обосновать в issue и поднять базу тем же коммитом');
}
}
if (shrunk.length) {
// Снижение без записи в базу — тоже отказ: храповик работает в обе стороны.
fail = true;
for (const s of shrunk) lines.push(`FAIL связность упала, база не опущена: ${s.name} ${s.base} → ${s.now} — node scripts/unused-locals-gate.mjs --update`);
}
if (!grown.length && !shrunk.length) lines.push('ok все числа равны базе');
if (metrics.bundleBytes == null) {
fail = true;
lines.push('FAIL dist/ не собран — bundleBytes не судится; сначала npm run build');
}
return { fail, lines, grown, shrunk };
}
export function baselineFrom(metrics) {
const out = {};
for (const name of METRIC_NAMES) out[name] = metrics[name];
return out;
}
if (isMainModule(import.meta.url)) {
const root = process.cwd();
const update = process.argv.includes('--update');
const json = process.argv.includes('--json');
const result = collectMetrics(root);
if (update) {
if (result.metrics.bundleBytes == null) {
console.error('dist/ не собран — база без bundleBytes была бы ложью; сначала npm run build');
process.exit(1);
}
writeFileSync(resolve(root, BASELINE_FILE), `${JSON.stringify(baselineFrom(result.metrics), null, 2)}\n`);
console.log(`${BASELINE_FILE} записан: ${formatMetrics(result.metrics)}`);
}
const baseline = readBaseline(root);
const decision = decide({ metrics: result.metrics, violations: result.violations, baseline });
if (json) {
console.log(JSON.stringify({ metrics: result.metrics, allowed: result.allowed, violations: result.violations, ...decision }, null, 2));
} else {
for (const line of decision.lines) console.log(line);
}
process.exit(decision.fail && !update ? 1 : 0);
}