mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Linux CI при параллельном npm test мог потерять последнюю отдельную запись stdout после длинной таблицы --count. Формируем отчёт целиком и пишем его одним вызовом, чтобы итоговая строка всегда доходила до unit-теста. Issue: #645 User-Visible: no
352 lines
17 KiB
JavaScript
352 lines
17 KiB
JavaScript
#!/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)));
|
||
}
|