mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test(harness): тестовый фасад window.__hpTest и гейт no-new-private-writes (#629)
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют записей в приватное состояние карточки (присваивание, ++/--, delete по цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/ _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока — movedLinesByFile из no-new-any; исключение // private-ok: <причина>. --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с no-new-any, с той же базой. - demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog, openSpaceDialog, setServerConfig, setLayout, input, close); ставится launch*() из demo/serve.mjs. В бандле фасада нет. - demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated, __pushServerConfig/__pushServerLayout; после доставки запись со старым expected_rev — conflict, как у настоящего сервера. - HP_SMOKE_CHECKS=1 печатает имена проверок в finish(). - smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без потери утверждений; новый smoke_test_facade доказывает каждую операцию. - 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md. Issue: #629 User-Visible: no
This commit is contained in:
@@ -61,8 +61,8 @@ export const NOT_AN_INPUT = [
|
||||
['demo/capture_*.mjs', 'ручные съёмки эталонов, запускаются человеком'],
|
||||
['demo/verify_ha_dialog_discard_recovery.mjs', 'явная тяжёлая диагностика настоящего ha-dialog для #607; запускается человеком'],
|
||||
['demo/verify_ha_form_shell_609.mjs', 'явная тяжёлая диагностика оболочки форм в настоящем ha-dialog для #609; запускается человеком'],
|
||||
['demo/helpers/ha-dialog-assets.mjs', 'пиновые HA assets только для явной диагностической съёмки #505; не запускают загрузку в Validate'],
|
||||
['demo/helpers/ha-dialog-fixture.mjs', 'изолированный настоящий ha-dialog только для явной визуальной приёмки #505, не обычный smoke'],
|
||||
// demo/helpers/** больше не здесь (#629): их текст судит no-new-private-writes
|
||||
// в job frontend, так что фикстуры ha-dialog #505 стали её входом.
|
||||
['demo/screencast_visual_continuity.mjs', 'ручной скринкаст'],
|
||||
['demo/gen_icons.mjs', 'генератор иконок демо-страницы, запускается вручную; результат в demo/srv/assets (класс D)'],
|
||||
['demo/downgrade_open_passage.mjs', 'ручной инструмент миграции фикстур'],
|
||||
@@ -291,7 +291,7 @@ const BUILD_INPUTS = ['src/**', 'package.json', 'package-lock.json', 'rollup.con
|
||||
'scripts/bundle-sync.mjs', 'scripts/bundle-tree.mjs'];
|
||||
/** Протокол браузерного харнеса: страница, сервер, гард исключений, compat-хелперы. */
|
||||
const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
|
||||
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**'];
|
||||
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**', 'demo/helpers/hp-test.mjs'];
|
||||
const WORKFLOW = ['.github/workflows/validate.yml'];
|
||||
/** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */
|
||||
const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'];
|
||||
@@ -320,9 +320,10 @@ export const CHECKS = {
|
||||
},
|
||||
frontend: {
|
||||
// npm run typecheck, npm test, npm run build, bundle:budget, lint:unused (#624)
|
||||
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/bundle-budget.mjs', 'scripts/fix-test-build.mjs',
|
||||
'scripts/unused-locals-gate.mjs'],
|
||||
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', ...WORKFLOW],
|
||||
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/bundle-budget.mjs',
|
||||
'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'],
|
||||
// demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст.
|
||||
roots: [...BUILD_INPUTS, 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
|
||||
},
|
||||
changed_mutants: {
|
||||
entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
|
||||
|
||||
@@ -63,6 +63,8 @@ export function parallelSteps(base) {
|
||||
return [
|
||||
{ name: 'сборка + typecheck (npm run build)', cmd: npm, args: ['run', 'build'] },
|
||||
{ name: 'новый код не добавляет any', cmd: process.execPath, args: ['scripts/no-new-any.mjs', '--base', base, '--head', 'HEAD'] },
|
||||
// #629: смоки не добавляют записей в приватное состояние карточки.
|
||||
{ name: 'смоки не пишут в приватное состояние', cmd: process.execPath, args: ['scripts/no-new-private-writes.mjs', '--base', base, '--head', 'HEAD'], hint: 'window.__hpTest (docs/TESTING.md) или // private-ok: <причина>' },
|
||||
{ name: 'смоки по диффу (smoke-select)', cmd: process.execPath, args: ['scripts/smoke-select.mjs', '--base', base, '--head', 'HEAD', '--json'], informational: true },
|
||||
];
|
||||
}
|
||||
@@ -106,7 +108,7 @@ export function summarize(results) {
|
||||
|
||||
export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = false, jobs = 2, log = console.log } = {}) {
|
||||
const started = Date.now();
|
||||
log(`gate:small — база диапазона ${base}; параллельно: сборка, no-new-any, smoke-select; после сборки: юниты и проверки артефакта${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
|
||||
log(`gate:small — база диапазона ${base}; параллельно: сборка, no-new-any, no-new-private-writes, smoke-select; после сборки: юниты и проверки артефакта${smokes ? `; затем bundle-sync и смоки по диффу (×${jobs})` : ''}`);
|
||||
const parallel = await Promise.all(parallelSteps(base).map((step) => runStep(step, cwd)));
|
||||
const buildOk = parallel.find((r) => r.args.includes('build'))?.code === 0;
|
||||
const serial = [];
|
||||
|
||||
@@ -4932,6 +4932,51 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if (exemption) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'private-writes-ignores-update-expressions',
|
||||
guard: 'node --test --test-name-pattern="во всех формах" test/no-new-private-writes.test.mjs',
|
||||
because: '`c._cfgEpoch++` — одна из самых частых записей смоков в приватное состояние '
|
||||
+ '(202 на 22.09); гейт, не видящий ++/--, пропускает ровно тот обход, ради которого '
|
||||
+ 'заведён (#629 AC1)',
|
||||
patches: [{
|
||||
file: 'scripts/no-new-private-writes.mjs',
|
||||
find: ' if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && UPDATE_OPERATORS.has(node.operator)) {',
|
||||
replace: ' if (false) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'private-writes-credits-any-field',
|
||||
guard: 'node --test --test-name-pattern="правка зачитывается только" test/no-new-private-writes.test.mjs',
|
||||
because: 'зачёт правки без сверки поля превращает любую уборку старой записи в индульгенцию '
|
||||
+ 'на новую запись в другое поле — гейт перестаёт держать приращение (#629 AC2)',
|
||||
patches: [{
|
||||
file: 'scripts/no-new-private-writes.mjs',
|
||||
find: 'const creditKey = (site) => `${site.kind}:${site.field}`;',
|
||||
replace: 'const creditKey = (site) => site.kind;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'private-writes-accepts-bare-marker',
|
||||
guard: 'node --test --test-name-pattern="private-ok проходит только" test/no-new-private-writes.test.mjs',
|
||||
because: 'голый `// private-ok` — обход гейта одной строкой; без проверки причины исключение '
|
||||
+ 'перестаёт что-либо значить, как и у any-ok (#342, #629 AC3)',
|
||||
patches: [{
|
||||
file: 'scripts/no-new-private-writes.mjs',
|
||||
find: ' if (exempt && exempt.ok) continue;',
|
||||
replace: ' if (exempt) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'private-writes-skips-covered-calls',
|
||||
guard: 'node --test --test-name-pattern="вызов, покрытый фасадом" test/no-new-private-writes.test.mjs',
|
||||
because: '265 вызовов `_setMode(` продолжали бы расти рядом с фасадом, который нажимает '
|
||||
+ 'настоящую вкладку режима; без G5 фасад необязателен (#629 AC4)',
|
||||
patches: [{
|
||||
file: 'scripts/no-new-private-writes.mjs',
|
||||
find: ' if (ts.isCallExpression(node)) {',
|
||||
replace: ' if (false) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'invariant-hidden-counts-corner-touch',
|
||||
guard: 'node --test --test-name-pattern="касание углом" test/model-invariants.test.mjs',
|
||||
@@ -10625,6 +10670,40 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " .preventScrimClose=${!this.dismissOnScrim}\n .ariaLabelledBy=${this._titleId}\n @opened=${this._focusInitial}",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'room-settings-click-does-not-open',
|
||||
guard: 'node demo/smoke_test_facade.mjs',
|
||||
because: 'смоки открывали диалог комнаты вызовом `_openRoomEdit` и оставались зелёными при '
|
||||
+ 'сломанной шестерёнке; фасад нажимает её по-настоящему и обязан это заметить (#629 AC6)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' @click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>',
|
||||
replace: ' @click=${(e: Event) => { e.stopPropagation(); }}>',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hp-dialog-escape-does-not-close',
|
||||
guard: 'node demo/smoke_test_facade.mjs',
|
||||
because: 'путь закрытия Escape не проверял ни один из 260 смоков — они закрывали диалог присваиванием null; '
|
||||
+ '`__hpTest.close` шлёт настоящий keydown и обязан увидеть, что диалог остался (#629 AC6)',
|
||||
patches: [{
|
||||
file: 'src/hp-dialog.ts',
|
||||
find: " this._closeOverlay(overlay, 'escape');\n return;\n }\n this._requestClose();\n return;",
|
||||
replace: " this._closeOverlay(overlay, 'escape');\n return;\n }\n return;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'config-updated-event-ignored',
|
||||
guard: 'node demo/smoke_test_facade.mjs',
|
||||
because: '`setServerConfig` доставляет конфиг событием houseplan_config_updated, как другой '
|
||||
+ 'клиент HA; карточка, пропускающая событие, должна краснить фасад, а не молча '
|
||||
+ 'показывать старый план (#629 AC6)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' if (observedRev !== this._cfgRev) void this._reloadConfigOnly(false, observedRev);',
|
||||
replace: ' void observedRev;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'version-seam-ignores-override',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/card-version.test.mjs',
|
||||
|
||||
+18
-2
@@ -70,7 +70,17 @@ const MIN_REASON_LENGTH = 12;
|
||||
* `ok: false` означает, что маркер есть, но обоснования в нём нет.
|
||||
*/
|
||||
export function parseAnyOk(lineText) {
|
||||
const match = /\/\/\s*any-ok\b\s*:?\s*(.*)$/.exec(String(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();
|
||||
@@ -158,7 +168,7 @@ export const MOVED_BLOCK_MIN = 5;
|
||||
* Возвращает по файлу номера таких строк. Сравнение точное, без обрезки
|
||||
* пробелов: перенос с изменённым отступом — уже правка, и судить её гейт обязан.
|
||||
*/
|
||||
export function movedLinesByFile(diff, { minBlock = MOVED_BLOCK_MIN } = {}) {
|
||||
export function movedLinesByFile(diff, { minBlock = MOVED_BLOCK_MIN, details } = {}) {
|
||||
const removedByFile = new Map();
|
||||
const addedByFile = new Map();
|
||||
let target = null;
|
||||
@@ -246,6 +256,12 @@ export function movedLinesByFile(diff, { minBlock = MOVED_BLOCK_MIN } = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Удалённые строки и те из них, что ушли на оплату переноса, — для гейтов,
|
||||
// которым нужен зачёт правок без двойного счёта (#629).
|
||||
if (details) {
|
||||
details.removedByFile = removedByFile;
|
||||
details.spent = spent;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Смоки не добавляют записей в приватное состояние карточки (#629).
|
||||
*
|
||||
* node scripts/no-new-private-writes.mjs # origin/dev...HEAD
|
||||
* node scripts/no-new-private-writes.mjs --base origin/dev --head HEAD
|
||||
* node scripts/no-new-private-writes.mjs --diff patch.diff # или `-` для stdin
|
||||
* node scripts/no-new-private-writes.mjs --count # остаток на HEAD, не гейт
|
||||
*
|
||||
* Зачем. Смок, который открывает диалог присваиванием `c._roomDialog = {...}` и
|
||||
* меняет план присваиванием `c._serverCfg = …`, зелёный и тогда, когда кнопка,
|
||||
* поле ввода и путь закрытия сломаны: он обходит ровно то, что должен
|
||||
* проверять. И каждое переименование поля в монолите красит десятки таких
|
||||
* смоков без изменения поведения. На 22.09 записей было больше двух тысяч;
|
||||
* переписать их разом — месяц работы, поэтому гейт, как `no-new-any` (#342),
|
||||
* держит только приращение: судятся добавленные строки диффа.
|
||||
*
|
||||
* Что считается записью (G1). Разбор настоящим парсером TypeScript: присваивание
|
||||
* любым оператором, `++`/`--`, `delete`, если в цепочке доступа левой части есть
|
||||
* сегмент `_x` (`.x` или `['x']`). `c._serverCfg.model_version = 7` — запись в
|
||||
* `_serverCfg`; `window.__card = …` и `o.ok = …` — нет. Цепочка от `this` —
|
||||
* собственный объект страницы, не карточка, и не считается.
|
||||
*
|
||||
* Вызов, покрытый фасадом (G5): `._setMode(`, `._openRoomEdit(`,
|
||||
* `._openMarkerDialog(`, `._openSpaceDialog(` — для них есть операция
|
||||
* `window.__hpTest` (demo/helpers/hp-test.mjs), которая нажимает настоящую
|
||||
* кнопку.
|
||||
*
|
||||
* Зачёт (G3). Правка строки — не новая ответственность, если поле то же:
|
||||
* каждая удалённая запись в поле F файла X оплачивает одну добавленную в то же
|
||||
* F того же X. Перенос непрерывного куска из одного файла в другой — не новый
|
||||
* код; куски находит `movedLinesByFile` из `no-new-any` (#592), и удалённые
|
||||
* строки, ушедшие на оплату переноса, второй раз правку не оплачивают.
|
||||
*
|
||||
* Исключение (G4) — на той же строке: `// private-ok: <конкретная причина>`,
|
||||
* с теми же требованиями к причине, что у `any-ok`.
|
||||
*
|
||||
* Чего гейт не видит — мутации через вызовы (`c._serverCfg.spaces.push(...)`,
|
||||
* `Object.assign(c, …)`) и записи через локальный псевдоним. Это закрывает
|
||||
* правило ревью (PROCESS.md §2.7): приватное поле — только для чтения в
|
||||
* ассертах.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import { addedLinesByFile, movedLinesByFile, parseExemption } from './no-new-any.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Имя сегмента, который считается приватным состоянием карточки. */
|
||||
const PRIVATE_SEGMENT = /^_[A-Za-z$]/;
|
||||
|
||||
/** Приватные вызовы, для которых у `window.__hpTest` есть операция (G5). */
|
||||
export const COVERED_CALLS = {
|
||||
_setMode: 'setMode',
|
||||
_openRoomEdit: 'openRoomEdit',
|
||||
_openMarkerDialog: 'openMarkerDialog',
|
||||
_openSpaceDialog: 'openSpaceDialog',
|
||||
};
|
||||
|
||||
/** Подсказка: какой публичной поверхностью заменить запись в поле. */
|
||||
const FIELD_HINTS = {
|
||||
_serverCfg: 'setServerConfig', _cfgEpoch: 'setServerConfig', _modelCache: 'setServerConfig',
|
||||
_frame: 'setServerConfig', _regSignature: 'setServerConfig', _layout: 'setLayout',
|
||||
_tool: 'setTool', _mode: 'setMode', _space: 'switchSpace',
|
||||
_markerDialog: 'openMarkerDialog + input + close', _spaceDialog: 'openSpaceDialog + input + close',
|
||||
_roomDialog: 'openRoomEdit + input + close',
|
||||
};
|
||||
|
||||
/** Область гейта (G2): смоки и хелперы харнесса. */
|
||||
export const isGatedPath = (path) => /^demo\/smoke_[^/]*\.mjs$/.test(path)
|
||||
|| /^demo\/helpers\/(?:.+\/)?[^/]+\.mjs$/.test(path);
|
||||
|
||||
/** Разобрать исключение `// private-ok: …`; `null`, если маркера нет. */
|
||||
export const parsePrivateOk = (lineText) => parseExemption(lineText, 'private-ok');
|
||||
|
||||
const UPDATE_OPERATORS = new Set([ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken]);
|
||||
const isAssignment = (kind) => kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
|
||||
|
||||
/**
|
||||
* Приватный сегмент цепочки доступа, ближайший к её корню, либо `null`.
|
||||
* Корень `this` — не карточка (G1).
|
||||
*/
|
||||
export function privateFieldOf(expression) {
|
||||
let field = null;
|
||||
let node = expression;
|
||||
for (;;) {
|
||||
if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)
|
||||
|| ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
|
||||
node = node.expression;
|
||||
} else if (ts.isPropertyAccessExpression(node)) {
|
||||
if (ts.isIdentifier(node.name) && PRIVATE_SEGMENT.test(node.name.text)) field = node.name.text;
|
||||
node = node.expression;
|
||||
} else if (ts.isElementAccessExpression(node)) {
|
||||
const key = node.argumentExpression;
|
||||
if (key && ts.isStringLiteralLike(key) && PRIVATE_SEGMENT.test(key.text)) field = key.text;
|
||||
node = node.expression;
|
||||
} else if (ts.isCallExpression(node)) {
|
||||
node = node.expression;
|
||||
} else break;
|
||||
}
|
||||
if (node.kind === ts.SyntaxKind.ThisKeyword) return null;
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Цели присваивания, включая деструктуризацию `[a, c._x] = …`. */
|
||||
function assignmentTargets(left) {
|
||||
if (ts.isArrayLiteralExpression(left)) {
|
||||
return left.elements.flatMap((el) => assignmentTargets(ts.isSpreadElement(el) ? el.expression : el));
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(left)) {
|
||||
return left.properties.flatMap((prop) => {
|
||||
if (ts.isPropertyAssignment(prop)) return assignmentTargets(prop.initializer);
|
||||
if (ts.isSpreadAssignment(prop)) return assignmentTargets(prop.expression);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (ts.isBinaryExpression(left) && left.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
||||
return assignmentTargets(left.left); // значение по умолчанию в деструктуризации
|
||||
}
|
||||
return [left];
|
||||
}
|
||||
|
||||
/** Имя приватного метода, если вызов покрыт фасадом (G5). */
|
||||
function coveredCallOf(call) {
|
||||
const callee = call.expression;
|
||||
let name = null;
|
||||
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) name = callee.name.text;
|
||||
else if (ts.isElementAccessExpression(callee) && callee.argumentExpression
|
||||
&& ts.isStringLiteralLike(callee.argumentExpression)) name = callee.argumentExpression.text;
|
||||
if (!name || !Object.hasOwn(COVERED_CALLS, name)) return null;
|
||||
if (rootIsThis(callee.expression)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function rootIsThis(expression) {
|
||||
let node = expression;
|
||||
while (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)
|
||||
|| ts.isCallExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) {
|
||||
node = node.expression;
|
||||
}
|
||||
return node.kind === ts.SyntaxKind.ThisKeyword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Места записи и покрытых вызовов в тексте: `{ line, field, kind }`,
|
||||
* `kind` — `write` или `call`. Строка — начало цели (или вызываемого).
|
||||
*/
|
||||
export function privateWriteSites(path, text) {
|
||||
const source = ts.createSourceFile(path, String(text), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
const sites = [];
|
||||
const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
||||
const record = (target, kind) => {
|
||||
const field = privateFieldOf(target);
|
||||
if (field) sites.push({ line: lineOf(target), field, kind });
|
||||
};
|
||||
const visit = (node) => {
|
||||
if (ts.isBinaryExpression(node) && isAssignment(node.operatorToken.kind)) {
|
||||
for (const target of assignmentTargets(node.left)) record(target, 'write');
|
||||
}
|
||||
if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && UPDATE_OPERATORS.has(node.operator)) {
|
||||
record(node.operand, 'write');
|
||||
}
|
||||
if (ts.isDeleteExpression(node)) record(node.expression, 'write');
|
||||
if (ts.isCallExpression(node)) {
|
||||
const method = coveredCallOf(node);
|
||||
if (method) sites.push({ line: lineOf(node.expression), field: method, kind: 'call' });
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return sites.sort((a, b) => a.line - b.line);
|
||||
}
|
||||
|
||||
/** Ключ зачёта: удалённая запись оплачивает только запись того же вида в то же поле. */
|
||||
const creditKey = (site) => `${site.kind}:${site.field}`;
|
||||
|
||||
/**
|
||||
* Бюджет зачёта по файлу: удалённые записи, не ушедшие на оплату переноса.
|
||||
* Удалённая строка разбирается отдельно — парсер терпим к незакрытым скобкам,
|
||||
* а для зачёта нужна только левая часть.
|
||||
*/
|
||||
export function creditsByFile(removedByFile, spent = new Map()) {
|
||||
const out = new Map();
|
||||
for (const [path, lines] of removedByFile || []) {
|
||||
const budget = new Map();
|
||||
lines.forEach((text, at) => {
|
||||
if (spent.get(path)?.has(at)) return;
|
||||
for (const site of privateWriteSites(path, text)) {
|
||||
const key = creditKey(site);
|
||||
budget.set(key, (budget.get(key) || 0) + 1);
|
||||
}
|
||||
});
|
||||
if (budget.size) out.set(path, budget);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hintFor(site) {
|
||||
if (site.kind === 'call') return `→ __hpTest.${COVERED_CALLS[site.field]}`;
|
||||
const op = FIELD_HINTS[site.field];
|
||||
return op ? `→ __hpTest.${op}` : '→ DOM с контрактными хуками, события фикстуры или __hpTest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Нарушения на добавленных строках.
|
||||
*
|
||||
* `files` — `{ path, text, addedLines, movedLines? }`; `credits` — из
|
||||
* `creditsByFile`. Изменённая строка в диффе выглядит добавленной: правка
|
||||
* судится, но запись в то же поле оплачивается удалённой.
|
||||
*/
|
||||
export function findNewPrivateWriteViolations({ files, credits = new Map() }) {
|
||||
const violations = [];
|
||||
for (const file of files) {
|
||||
const textLines = String(file.text).split('\n');
|
||||
const budget = new Map(credits.get(file.path) || []);
|
||||
for (const site of privateWriteSites(file.path, file.text)) {
|
||||
if (!file.addedLines.has(site.line)) continue;
|
||||
if (file.movedLines?.has(site.line)) continue;
|
||||
const lineText = textLines[site.line - 1] ?? '';
|
||||
const exempt = parsePrivateOk(lineText);
|
||||
if (exempt && exempt.ok) continue;
|
||||
const key = creditKey(site);
|
||||
if ((budget.get(key) || 0) > 0) { budget.set(key, budget.get(key) - 1); continue; }
|
||||
violations.push({
|
||||
path: file.path,
|
||||
line: site.line,
|
||||
field: site.field,
|
||||
kind: site.kind,
|
||||
text: lineText.trim(),
|
||||
reason: exempt
|
||||
? `маркер private-ok без конкретной причины: «${exempt.reason || '(пусто)'}»`
|
||||
: site.kind === 'call'
|
||||
? `вызов приватного ${site.field}() на добавленной строке ${hintFor(site)}`
|
||||
: `запись в приватное ${site.field} на добавленной строке ${hintFor(site)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
|
||||
}
|
||||
|
||||
/** Строка отчёта (G6): путь:строка, поле, причина; текст строки — следующей. */
|
||||
export function formatViolation(violation) {
|
||||
return ` ${violation.path}:${violation.line} [${violation.field}] — ${violation.reason}\n ${violation.text}`;
|
||||
}
|
||||
|
||||
/** Все файлы области G2 на диске. */
|
||||
function gatedFiles(root) {
|
||||
const demo = join(root, 'demo');
|
||||
const out = [];
|
||||
for (const name of readdirSync(demo)) if (isGatedPath(`demo/${name}`)) out.push(`demo/${name}`);
|
||||
const walk = (dir, rel) => {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = `${rel}/${entry.name}`;
|
||||
if (entry.isDirectory()) walk(join(dir, entry.name), path);
|
||||
else if (isGatedPath(path)) out.push(path);
|
||||
}
|
||||
};
|
||||
walk(join(demo, 'helpers'), 'demo/helpers');
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** Остаток записей по файлам — информационный режим `--count`. */
|
||||
export function countWrites(root = ROOT) {
|
||||
const byFile = [];
|
||||
for (const path of gatedFiles(root)) {
|
||||
const sites = privateWriteSites(path, readFileSync(join(root, path), 'utf8'));
|
||||
const writes = sites.filter((site) => site.kind === 'write').length;
|
||||
const calls = sites.length - writes;
|
||||
if (writes || calls) byFile.push({ path, writes, calls });
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
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('--count')) {
|
||||
const byFile = countWrites(ROOT);
|
||||
const writes = byFile.reduce((sum, f) => sum + f.writes, 0);
|
||||
const calls = byFile.reduce((sum, f) => sum + f.calls, 0);
|
||||
for (const f of byFile.sort((a, b) => b.writes - a.writes || a.path.localeCompare(b.path))) {
|
||||
console.log(`${String(f.writes).padStart(5)} ${String(f.calls).padStart(5)} ${f.path}`);
|
||||
}
|
||||
console.log(`Итого: записей ${writes}, покрытых фасадом вызовов ${calls} в ${byFile.length} файл(ах).`);
|
||||
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}`, '--', 'demo',
|
||||
], { 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 details = {};
|
||||
const movedByFile = movedLinesByFile(diff, { details });
|
||||
const credits = creditsByFile(details.removedByFile, details.spent);
|
||||
const files = [];
|
||||
for (const [path, addedLines] of added) {
|
||||
if (!isGatedPath(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 = findNewPrivateWriteViolations({ files, credits });
|
||||
const scanned = files.reduce((sum, file) => sum + file.addedLines.size, 0);
|
||||
console.log(`Проверено добавленных строк в demo/smoke_*.mjs и demo/helpers/**: ${scanned}`
|
||||
+ ` в ${files.length} файл(ах).`);
|
||||
if (!violations.length) {
|
||||
console.log('Новых записей в приватное состояние карточки нет.');
|
||||
return 0;
|
||||
}
|
||||
console.error(`\nНовая запись в приватное состояние карточки: ${violations.length}\n`);
|
||||
for (const violation of violations) console.error(formatViolation(violation));
|
||||
console.error('\nСмок входит в сценарий через публичную поверхность: DOM с контрактными хуками');
|
||||
console.error('(docs/data-hp-contract.json), события фикстуры, window.__hpTest (docs/TESTING.md, #629).');
|
||||
console.error('Приватное поле — только для чтения в ассертах. Если иначе нельзя — на той же строке:');
|
||||
console.error(' // private-ok: <конкретная причина, лучше со ссылкой на issue>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
Reference in New Issue
Block a user