mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
golden-capture-provenance accept() left a ~20 MB baselines sandbox in TMPDIR on every call (433 copies, 8.5 GB, ENOSPC in the shared sandbox). It now reads what it needs and removes the sandbox in finally; fixtures are removed via t.after. Same fix for the three other leaking sites (bundle-assets hp-tree-, docs-accept hp-identical-, rebase-generated hp-runner- outside finally). test/temp-dir-hygiene.test.mjs parses test/**/*.mjs with the TypeScript AST: every mkdtemp/mkdtempSync must be bound to a name removed by rm*/rmSync in a finally block or an after/afterEach/t.after hook of the same function, or returned by a named helper whose every call site does so; exception `// tmp-ok: <reason>`. Two mutants witness the lint. Issue: #646 User-Visible: no
267 lines
14 KiB
JavaScript
267 lines
14 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { extname, join, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import ts from 'typescript';
|
||
|
||
// #646. Тест, создавший временный каталог, обязан его убрать — даже когда
|
||
// падает. `golden-capture-provenance` оставлял по ≈20 МБ за прогон
|
||
// (`hp-golden-571-baselines-*`), и за сутки параллельной работы агентов общий
|
||
// TMPDIR вырос до 8,5 ГБ: ENOSPC, упавшая сборка и ложно красные юниты с
|
||
// временными git-репозиториями.
|
||
//
|
||
// Проверка статическая, по исходникам: рантайм видит утечку только на том
|
||
// пути, который исполнился, а исходник показывает намерение на всех путях.
|
||
//
|
||
// Правило. Каждый вызов `mkdtemp`/`mkdtempSync` в `test/**/*.mjs`:
|
||
// 1. связан с именем (`const dir = mkdtempSync(...)`) либо сразу возвращён;
|
||
// 2. это имя убирается вызовом `rm`/`rmSync`/`rmdirSync(<имя>, ...)`, стоящим
|
||
// в блоке `finally` или в колбэке `after`/`afterEach`/`t.after` внутри
|
||
// той же функции — очистка, которая не исполнится при упавшем assert, не
|
||
// считается;
|
||
// 3. либо функция — именованный помощник, который возвращает каталог (сам или
|
||
// полем объекта), и тогда правило 2 применяется к КАЖДОМУ месту его вызова
|
||
// (цепочка помощников — до MAX_DEPTH звеньев);
|
||
// 4. исключение — комментарий `// tmp-ok: <причина>` на строке вызова или на
|
||
// строке над ним. Причина обязательна: пустой `tmp-ok:` не принимается.
|
||
|
||
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||
const TEST_DIR = join(ROOT, 'test');
|
||
const MAX_DEPTH = 3;
|
||
const CREATORS = new Set(['mkdtemp', 'mkdtempSync']);
|
||
const REMOVERS = new Set(['rm', 'rmSync', 'rmdirSync', 'rmdir']);
|
||
const HOOKS = new Set(['after', 'afterEach']);
|
||
|
||
const calleeName = (call) => {
|
||
const callee = call.expression;
|
||
if (ts.isIdentifier(callee)) return callee.text;
|
||
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
|
||
return null;
|
||
};
|
||
|
||
const isFunctionLike = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
|
||
|| ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
|
||
|
||
const enclosingFunction = (node) => {
|
||
for (let cur = node.parent; cur; cur = cur.parent) if (isFunctionLike(cur)) return cur;
|
||
return node.getSourceFile();
|
||
};
|
||
|
||
/** Имя функции: объявление либо `const name = () => ...`. */
|
||
function functionName(fn) {
|
||
if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name.text;
|
||
if (ts.isMethodDeclaration(fn) && ts.isIdentifier(fn.name)) return fn.name.text;
|
||
const parent = fn.parent;
|
||
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
|
||
return null;
|
||
}
|
||
|
||
/** Куда уходит значение выражения: `{ name }`, `{ returned: true }` или `null`. */
|
||
function binding(expr) {
|
||
let node = expr;
|
||
while (node.parent && (ts.isAwaitExpression(node.parent) || ts.isParenthesizedExpression(node.parent))) {
|
||
node = node.parent;
|
||
}
|
||
const parent = node.parent;
|
||
if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
|
||
if (ts.isIdentifier(parent.name)) return { name: parent.name.text };
|
||
if (ts.isObjectBindingPattern(parent.name)) {
|
||
const fields = new Map();
|
||
for (const element of parent.name.elements) {
|
||
const key = element.propertyName ?? element.name;
|
||
if (ts.isIdentifier(key) && ts.isIdentifier(element.name)) fields.set(key.text, element.name.text);
|
||
}
|
||
return { fields };
|
||
}
|
||
return null;
|
||
}
|
||
if (ts.isReturnStatement(parent)) return { returned: true };
|
||
if (ts.isArrowFunction(parent) && parent.body === node) return { returned: true };
|
||
return null;
|
||
}
|
||
|
||
/** Узел `node` лежит в `finally` либо в колбэке хука очистки, не выходя за `scope`. */
|
||
function inCleanupPosition(node, scope) {
|
||
for (let cur = node; cur && cur !== scope; cur = cur.parent) {
|
||
const parent = cur.parent;
|
||
if (!parent) break;
|
||
if (ts.isTryStatement(parent) && parent.finallyBlock === cur) return true;
|
||
if (isFunctionLike(cur) && ts.isCallExpression(parent) && parent.arguments.includes(cur)
|
||
&& HOOKS.has(calleeName(parent))) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** В `scope` есть очистка каталога с именем `name` в безопасной позиции. */
|
||
function cleansUp(scope, name) {
|
||
let found = false;
|
||
const visit = (node) => {
|
||
if (found) return;
|
||
if (ts.isCallExpression(node) && REMOVERS.has(calleeName(node))) {
|
||
const [first] = node.arguments;
|
||
if (first && ts.isIdentifier(first) && first.text === name && inCleanupPosition(node, scope)) {
|
||
found = true;
|
||
return;
|
||
}
|
||
}
|
||
ts.forEachChild(node, visit);
|
||
};
|
||
ts.forEachChild(scope, visit);
|
||
return found;
|
||
}
|
||
|
||
/** Возвращает ли `fn` значение `name`: целиком (`''`) или полями объекта (их ключи). */
|
||
function returnedAs(fn, name) {
|
||
const keys = new Set();
|
||
const visit = (node) => {
|
||
if (node !== fn && isFunctionLike(node)) return;
|
||
const value = ts.isReturnStatement(node) ? node.expression
|
||
: (node === fn && ts.isArrowFunction(fn) && !ts.isBlock(fn.body) ? fn.body : null);
|
||
if (value) {
|
||
let expr = value;
|
||
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
|
||
if (ts.isIdentifier(expr) && expr.text === name) keys.add('');
|
||
if (ts.isObjectLiteralExpression(expr)) {
|
||
for (const prop of expr.properties) {
|
||
if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === name) keys.add(prop.name.text);
|
||
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.initializer)
|
||
&& prop.initializer.text === name && ts.isIdentifier(prop.name)) keys.add(prop.name.text);
|
||
}
|
||
}
|
||
}
|
||
ts.forEachChild(node, visit);
|
||
};
|
||
visit(fn);
|
||
return keys;
|
||
}
|
||
|
||
function exempt(lines, line) {
|
||
const reason = /\/\/\s*tmp-ok:\s*(\S.*)$/;
|
||
return reason.test(lines[line] ?? '') || reason.test(lines[line - 1] ?? '');
|
||
}
|
||
|
||
/**
|
||
* Нарушения в одном исходнике: `[{ line, message }]`, строки с 1.
|
||
* Экспортируется не наружу, а в самопроверку ниже: правило обязано краснеть
|
||
* на плохом примере, иначе зелёный результат ничего не доказывает.
|
||
*/
|
||
export function tempDirViolations(source, fileName = 'x.mjs') {
|
||
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||
const lines = source.split('\n');
|
||
const lineOf = (node) => file.getLineAndCharacterOfPosition(node.getStart(file)).line;
|
||
const calls = [];
|
||
const collect = (node) => {
|
||
if (ts.isCallExpression(node)) calls.push(node);
|
||
ts.forEachChild(node, collect);
|
||
};
|
||
collect(file);
|
||
|
||
const violations = [];
|
||
// Проверка значения `expr`, созданного на строке `origin`: убрано ли оно.
|
||
const check = (expr, depth, origin, path) => {
|
||
const bound = binding(expr);
|
||
const scope = enclosingFunction(expr);
|
||
const where = `${path} (строка ${lineOf(expr) + 1})`;
|
||
if (!bound) return [`${where}: каталог не связан с именем — убрать его нечем`];
|
||
if (bound.name && cleansUp(scope, bound.name)) return [];
|
||
const names = bound.name ? [bound.name] : bound.fields ? [...bound.fields.values()] : [];
|
||
if (names.some((name) => cleansUp(scope, name))) return [];
|
||
// Не убрано здесь — может быть, это помощник, отдающий каталог наружу.
|
||
const returnedKeys = bound.returned ? new Set(['']) : bound.name ? returnedAs(scope, bound.name) : new Set();
|
||
if (!returnedKeys.size) {
|
||
return [`${where}: нет rm*/rmSync(${names[0] ?? '…'}) в finally или after-хуке той же функции`];
|
||
}
|
||
if (depth >= MAX_DEPTH) return [`${where}: цепочка помощников длиннее ${MAX_DEPTH}`];
|
||
const helper = scope === file ? null : functionName(scope);
|
||
if (!helper) return [`${where}: каталог возвращается из безымянной функции — вызовы не проследить`];
|
||
const sites = calls.filter((call) => ts.isIdentifier(call.expression) && call.expression.text === helper);
|
||
const problems = [];
|
||
for (const site of sites) {
|
||
const siteBound = binding(site);
|
||
// Поле объекта, которое вызывающий не забрал, — утечка: каталог никто не удалит.
|
||
if (siteBound?.fields && ![...returnedKeys].some((key) => key && siteBound.fields.has(key))) {
|
||
problems.push(`${path} (строка ${lineOf(site) + 1}): ${helper}() отдаёт каталог полем `
|
||
+ `{${[...returnedKeys].filter(Boolean).join(', ')}}, вызывающий его не забирает`);
|
||
continue;
|
||
}
|
||
if (siteBound?.fields) {
|
||
const taken = [...returnedKeys].filter((key) => key && siteBound.fields.has(key))
|
||
.map((key) => siteBound.fields.get(key));
|
||
const siteScope = enclosingFunction(site);
|
||
if (taken.some((name) => cleansUp(siteScope, name))) continue;
|
||
problems.push(`${path} (строка ${lineOf(site) + 1}): нет очистки {${taken.join(', ')}} из ${helper}() `
|
||
+ 'в finally или after-хуке');
|
||
continue;
|
||
}
|
||
problems.push(...check(site, depth + 1, origin, `${path} → ${helper}()`));
|
||
}
|
||
return problems;
|
||
};
|
||
|
||
for (const call of calls) {
|
||
if (!CREATORS.has(calleeName(call))) continue;
|
||
const line = lineOf(call);
|
||
if (exempt(lines, line)) continue;
|
||
for (const message of check(call, 0, line, `${fileName}:${line + 1}`)) {
|
||
violations.push({ line: line + 1, message });
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
const testSources = (dir) => readdirSync(dir, { withFileTypes: true })
|
||
.flatMap((entry) => entry.isDirectory()
|
||
? testSources(join(dir, entry.name))
|
||
: extname(entry.name) === '.mjs' ? [join(dir, entry.name)] : []);
|
||
|
||
test('#646 AC2: каждый mkdtemp в тестах убирается в finally или after-хуке', () => {
|
||
const files = testSources(TEST_DIR);
|
||
let sites = 0;
|
||
const problems = [];
|
||
for (const path of files) {
|
||
const source = readFileSync(path, 'utf8');
|
||
if (!/mkdtemp/.test(source)) continue;
|
||
const name = relative(ROOT, path).replaceAll('\\', '/');
|
||
sites += (source.match(/\bmkdtemp(?:Sync)?\s*\(/g) ?? []).length;
|
||
problems.push(...tempDirViolations(source, name).map((v) => v.message));
|
||
}
|
||
// Разбор, не нашедший ни одного вызова, доказал бы только то, что он сломан.
|
||
assert.ok(sites >= 20, `ожидались десятки вызовов mkdtemp в test/, найдено ${sites}`);
|
||
assert.deepEqual(problems, [], `временные каталоги без очистки:\n${problems.join('\n')}`);
|
||
});
|
||
|
||
test('#646 AC2: правило краснеет на утечке и молчит на корректной очистке', () => {
|
||
const bad = (src) => tempDirViolations(src).length > 0;
|
||
// Очистка без finally не исполнится после упавшего assert.
|
||
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); work(d); rmSync(d, { recursive: true }); });`));
|
||
// Очистки нет вовсе.
|
||
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); work(d); });`));
|
||
// Очищен не тот каталог.
|
||
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); const e = 'b'; try {} finally { rmSync(e); } });`));
|
||
// Результат не связан с именем.
|
||
assert.ok(bad(`test('x', () => { work(mkdtempSync('a')); });`));
|
||
// Помощник отдаёт каталог, вызывающий не убирает.
|
||
assert.ok(bad(`function f() { const d = mkdtempSync('a'); return d; }
|
||
test('x', () => { const d = f(); try {} finally {} });`));
|
||
// Помощник отдаёт каталог полем объекта, вызывающий это поле не забирает (#646: accept()).
|
||
assert.ok(bad(`function f() { const sandbox = mkdtempSync('a'); return { index: 1, sandbox }; }
|
||
test('x', () => { const { index } = f(); });`));
|
||
// Пустая причина исключения не принимается.
|
||
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); // tmp-ok:
|
||
});`));
|
||
|
||
const good = (src) => assert.deepEqual(tempDirViolations(src), [], src);
|
||
good(`test('x', () => { const d = mkdtempSync('a'); try { work(d); } finally { rmSync(d, { recursive: true, force: true }); } });`);
|
||
good(`test('x', (t) => { const d = mkdtempSync('a'); t.after(() => rmSync(d, { recursive: true, force: true })); });`);
|
||
good(`test('x', async () => { const d = await mkdtemp('a'); try {} finally { await fs.rm(d, { recursive: true }); } });`);
|
||
good(`function f() { const d = mkdtempSync('a'); return d; }
|
||
test('x', () => { const d = f(); try {} finally { rmSync(d, { recursive: true }); } });`);
|
||
good(`const f = () => mkdtempSync('a');
|
||
test('x', (t) => { const r = f(); t.after(() => rmSync(r, { recursive: true })); });`);
|
||
good(`function f() { const root = mkdtempSync('a'); return { root, x: 1 }; }
|
||
test('x', () => { const { root } = f(); try {} finally { rmSync(root, { recursive: true }); } });`);
|
||
good(`test('x', () => { const d = mkdtempSync('a'); // tmp-ok: каталог уносит дочерний процесс
|
||
});`);
|
||
});
|