Files
houseplan-card/scripts/no-new-private-writes.mjs
T
Sergey Matyunin a8437e69e5 fix(ci): атомарно печатать отчёт private-writes (#645)
Linux CI при параллельном npm test мог потерять последнюю отдельную запись
stdout после длинной таблицы --count. Формируем отчёт целиком и пишем его
одним вызовом, чтобы итоговая строка всегда доходила до unit-теста.

Issue: #645
User-Visible: no
2026-09-25 08:58:05 +03:00

352 lines
17 KiB
JavaScript
Raw 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
/**
* Смоки не добавляют записей в приватное состояние карточки (#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);
const lines = byFile
.sort((a, b) => b.writes - a.writes || a.path.localeCompare(b.path))
.map((f) => `${String(f.writes).padStart(5)} ${String(f.calls).padStart(5)} ${f.path}`);
lines.push(`Итого: записей ${writes}, покрытых фасадом вызовов ${calls} в ${byFile.length} файл(ах).`);
process.stdout.write(`${lines.join('\n')}\n`);
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)));
}