Files
houseplan-card/scripts/unused-locals-gate.mjs
Claudeandclaude[bot] c68d92f674 process: ratchets get a band over the beta ceiling (#699)
Two-sided ratchets with zero slack made parallel tasks conflict on shared
numbers, recompute them after every rebase and hit a ceiling because a
neighbour merged first (#689 after #691).

- Core lines (test/core-file-budget.test.mjs): a branch may grow up to
  CORE_BAND = 50 lines over the beta ceiling; shrinking no longer fails it.
- Bundle graphs (bundle-budget.mjs): initial View and lazy graphs fail only
  above ceiling + 2 000 B; below the ceiling is not a branch finding. The
  absolute INITIAL_VIEW_GZIP_BUDGET stays the wall.
- Monolith numbers (monolith-metrics.mjs, unused-locals-gate.mjs):
  METRIC_BANDS — 5 for delegates, port members and privates, 25 for host.
  refs, 2 000 B for dist/; a lower number is reported, not failed.
- Browser mutation guards: 200 is a guideline — mutation-gate --check warns
  above it instead of failing; every guard still needs its reason line.
- scripts/ratchets.mjs: `report [--warn]` and `tighten` — on the beta
  candidate the release manager sets every ceiling to the fact in one
  commit; release:prerelease prints loose ceilings as a warning.

Canon: PROCESS.md §3 (browser guards, monolith numbers) and §8 «Храповики»;
docs/TESTING.md.

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

108 lines
6.0 KiB
JavaScript

#!/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` больше своей полосы
* (`METRIC_BANDS`, #699). Снижение задачу не красит: базу до факта опускает
* бета (`node scripts/ratchets.mjs tighten`), иначе выигрыш монолит отыграет
* обратно. Рост сверх полосы допускается только с явной записью в 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, METRIC_BANDS,
} 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}`
+ ` — больше полосы ${METRIC_BANDS[g.name] ?? 0}; вернуть или обосновать в issue и поднять базу тем же коммитом`);
}
}
if (shrunk.length) {
// #699: снижение задачу не красит — базу до факта опускает бета
// (`node scripts/ratchets.mjs tighten`). Храповик по-прежнему двусторонний,
// но вторая сторона живёт на бете, а не в каждой ветке.
for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);
}
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);
}