Files
houseplan-card/test/monolith-metrics.test.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

169 lines
11 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.
// #624: шесть чисел связности монолита и гейт мёртвого кода — один модуль
// для `lint:unused` и `inventory`. Имена файлов монолита — через константы
// модуля: этот тест не читает монолит и не должен попадать в заморозку якорей. Фикстуры с известными числами: делегаты во
// всех формах, порт, `host.`, разбор диагностик, байты dist, храповик.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_BANDS, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
harnessMemberNames, hostReferences, portMemberNames, readBaseline,
} from '../scripts/monolith-metrics.mjs';
import { baselineFrom, decide } from '../scripts/unused-locals-gate.mjs';
const CARD = `
import { html } from 'lit';
class HouseplanCard {
private _a = 1;
// делегаты: return, async, без return, геттер, await
private _saveMarker(): void { return this._editorRuntimeOrThrow()._saveMarker(); }
private async _saveRoom(id: string): Promise<void> { return this._editorRuntimeOrThrow()._saveRoom(id); }
private _open(): void { this._editorRuntimeOrThrow()._open(); }
private get _tool(): string { return this._editorRuntimeOrThrow()._tool; }
private async _apply(): Promise<void> { await this._editorRuntimeOrThrow()._apply(); }
// не делегаты: два выражения, свой код, сам аксессор
private _two(): void { this._a += 1; this._editorRuntimeOrThrow()._open(); }
private _own(): number { return this._a * 2; }
private _runtime() { return this._editorRuntimeOrThrow(); }
private _editorRuntimeOrThrow(): any { return null; }
}
`;
const RUNTIME = `
export interface HouseplanEditorHostPort {
_a: number;
_tool: string;
_saveRoom(id: string): Promise<void>;
readonly _model: unknown[];
'quoted-name'?: string;
}
class HouseplanEditorRuntime {
constructor(public host: HouseplanEditorHostPort) {}
run() { return this.host._a + this.host._tool.length; }
}
`;
test('#624 делегаты считаются по AST во всех формах, свой код — нет', () => {
assert.equal(countDelegates(CARD), 5);
assert.equal(countDelegates('class HouseplanCard { private _x() { return 1; } }'), 0);
assert.throws(() => countDelegates('class Other {}'), /HouseplanCard/);
});
test('#624 члены порта и обращения host. считаются по именам', () => {
const port = portMemberNames(RUNTIME);
assert.deepEqual([...port].sort(), ['_a', '_model', '_saveRoom', '_tool', 'quoted-name']);
const refs = hostReferences({
[RUNTIME_FILE]: RUNTIME,
'src/other.ts': 'const x = host._model; const y = port.host._a; ghost._z; hostile._q;',
[CARD_FILE]: 'host._ignored; host._ignored2;', // карточка не считается
});
assert.equal(refs.count, 4, 'два в рантайме + два в other; ghost./hostile. — не host.');
assert.deepEqual([...refs.names].sort(), ['_a', '_model', '_tool']);
});
test('#624 харнесс: имена, которых смоки касаются как свойств', () => {
const names = harnessMemberNames({
'demo/smoke_a.mjs': "card._saveMarker(); c._glowClipCache.clear(); Number(c._until || 0); el['_bracket'] = 1; card.publicName();",
});
assert.deepEqual([...names].sort(), ['_bracket', '_glowClipCache', '_saveMarker', '_until']);
});
test('#624 разбор диагностик: порт и харнесс разрешены и посчитаны, остальное — нарушение', () => {
const cardText = [
'class HouseplanCard {',
' private _portOnly = 1;',
' private _hostOnly(): void {}',
' private _harnessOnly = new Map();',
' private _dead = 2;',
' publicDead = 3;',
'}',
].join('\n');
const d = (line, name, file = CARD_FILE, code = 6133) => ({ file, line, code, message: `'${name}' is declared but its value is never read.` });
const { allowed, violations } = classifyUnused([
d(2, '_portOnly'), d(3, '_hostOnly'), d(4, '_harnessOnly'), d(5, '_dead'), d(6, 'publicDead'),
d(1, '_portOnly', 'src/other.ts'), // не карточка — не разрешается, даже если имя из порта
{ file: 'src/x.ts', line: 1, code: 6192, message: 'All imports in import declaration are unused.' },
], {
cardText, portNames: new Set(['_portOnly']), hostNames: new Set(['_hostOnly']), harnessNames: new Set(['_harnessOnly', '_dead_no']),
});
assert.deepEqual(allowed.port, ['_portOnly', '_hostOnly']);
assert.deepEqual(allowed.harness, ['_harnessOnly']);
assert.deepEqual(violations.map((v) => `${v.file}:${v.line}:${v.name}`), [
`${CARD_FILE}:5:_dead`, `${CARD_FILE}:6:publicDead`, 'src/other.ts:1:_portOnly', 'src/x.ts:1:',
]);
});
test('#624 байты dist — сумма всех файлов, включая чанки; без сборки — null', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-monolith-dist-'));
try {
mkdirSync(join(dir, 'dist', 'houseplan-assets'), { recursive: true });
writeFileSync(join(dir, 'dist', 'houseplan-card.js'), 'a'.repeat(10));
writeFileSync(join(dir, 'dist', 'houseplan-assets', 'chunk.js'), 'b'.repeat(1000));
assert.equal(bundleBytes(join(dir, 'dist')), 1010);
assert.equal(bundleBytes(join(dir, 'missing')), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#699 храповик: рост сверх полосы любого числа — красный, в полосе и снижение — зелёные', () => {
const base = { delegates: 10, portMembers: 20, hostRefs: 30, portPrivates: 4, harnessPrivates: 5, bundleBytes: 1000 };
assert.deepEqual(compareWithBaseline({ ...base }, base), { grown: [], shrunk: [] });
assert.deepEqual(METRIC_BANDS, { delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND });
for (const name of METRIC_NAMES.filter((n) => n !== 'bundleBytes')) {
const band = METRIC_BANDS[name];
assert.deepEqual(compareWithBaseline({ ...base, [name]: base[name] + band }, base).grown, [], `${name} +${band} — в полосе`);
const grown = compareWithBaseline({ ...base, [name]: base[name] + band + 1 }, base).grown;
assert.deepEqual(grown.map((g) => g.name), [name], `${name} сверх полосы виден как рост`);
const decision = decide({ metrics: { ...base, [name]: base[name] + band + 1 }, violations: [], baseline: base });
assert.equal(decision.fail, true, `${name} сверх полосы — красный гейт`);
assert.match(decision.lines.join('\n'), new RegExp(`связность выросла: ${name} `));
assert.equal(decide({ metrics: { ...base, [name]: base[name] + band }, violations: [], baseline: base }).fail, false);
}
// bundleBytes — с полосой, как gzip-потолок #438: чужой коммит в dev меняет
// dist на сотни байт, и точное число красило бы каждую ветку после ребейза.
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND }, base).grown, []);
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, base).grown.map((g) => g.name), ['bundleBytes']);
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes - BUNDLE_BYTES_BAND - 1 }, base).shrunk.map((g) => g.name), ['bundleBytes']);
assert.equal(decide({ metrics: { ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, violations: [], baseline: base }).fail, true);
// #699: снижение задачу не красит — базу опускает бета.
const shrunk = decide({ metrics: { ...base, delegates: 9 }, violations: [], baseline: base });
assert.equal(shrunk.fail, false);
assert.match(shrunk.lines.join('\n'), /ниже базы: delegates 10 → 9 — базу опустит бета/);
assert.equal(decide({ metrics: base, violations: [], baseline: base }).fail, false);
// Отсутствие числа в базе — рост (нельзя обнулить метрику, удалив ключ).
const { portPrivates, ...withoutOne } = base;
void portPrivates;
assert.deepEqual(compareWithBaseline(base, withoutOne).grown.map((g) => g.name), ['portPrivates']);
});
test('#624 гейт: нарушения noUnusedLocals красят даже при равной базе; без dist — красный; без базы — красный', () => {
const base = { delegates: 1, portMembers: 1, hostRefs: 1, portPrivates: 1, harnessPrivates: 1, bundleBytes: 1 };
const violation = { file: 'src/x.ts', line: 3, code: 6133, message: "'y' is declared but its value is never read.", name: 'y' };
const red = decide({ metrics: base, violations: [violation], baseline: base });
assert.equal(red.fail, true);
assert.match(red.lines.join('\n'), /src\/x\.ts:3 TS6133/);
assert.equal(decide({ metrics: { ...base, bundleBytes: null }, violations: [], baseline: base }).fail, true);
assert.equal(decide({ metrics: base, violations: [], baseline: null }).fail, true);
assert.deepEqual(Object.keys(baselineFrom({ ...base, extra: 1 })), METRIC_NAMES);
});
test('#624 живое дерево: база равна текущим числам, нарушений нет (gate:small и Validate судят то же)', () => {
const root = fileURLToPath(new URL('../', import.meta.url));
const baseline = readBaseline(root);
assert.ok(baseline, `${BASELINE_FILE} обязан существовать`);
// Компилятор гоняет вся программа (~8 с): один раз здесь, как и в гейте.
const { metrics, violations } = collectMetrics(root);
assert.deepEqual(violations, [], 'мёртвый код по noUnusedLocals вне порта и харнесса');
const { grown, shrunk } = compareWithBaseline(metrics, baseline);
// bundleBytes судит гейт после сборки; здесь пять чисел исходника, а
// фактический свежий dist отдельно проверяют bundle-budget и lint:unused.
const source = (list) => list.filter((s) => s.name !== 'bundleBytes');
assert.deepEqual(source(grown), [], 'связность выросла — вернуть или обосновать');
void shrunk; // #699: ниже базы — не ошибка ветки, базу опускает бета (ratchets.mjs tighten)
assert.equal(typeof readFileSync(join(root, BASELINE_FILE), 'utf8'), 'string');
});