test: temp dirs are removed even when a test fails; lint guards it (#646)

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
This commit is contained in:
Claude
2026-09-25 03:50:40 +03:00
parent 524908b23b
commit fa91f3a731
6 changed files with 357 additions and 60 deletions
+22
View File
@@ -2988,6 +2988,28 @@ const MUTANT_DEFINITIONS = [
replace: ' pass',
}],
},
{
id: 'golden-accept-sandbox-leaks',
guard: 'node --test test/temp-dir-hygiene.test.mjs',
because: 'the ~20 MB baselines sandbox of accept() outliving every call is what filled the '
+ 'shared TMPDIR to 8.5 GB and ENOSPC (#646 AC2)',
patches: [{
file: 'test/golden-capture-provenance.test.mjs',
find: ' rmSync(sandbox, { recursive: true, force: true });',
replace: ' // sandbox is left for inspection',
}],
},
{
id: 'temp-hygiene-accepts-cleanup-outside-finally',
guard: 'node --test test/temp-dir-hygiene.test.mjs',
because: 'a trailing rmSync after the asserts does not run when an assert fails, so a red '
+ 'test still leaks; the lint must not count it as cleanup (#646 AC2)',
patches: [{
file: 'test/temp-dir-hygiene.test.mjs',
find: 'function inCleanupPosition(node, scope) {',
replace: 'function inCleanupPosition(node, scope) {\n if (node) return true;',
}],
},
{
id: 'ha-harness-notice-silent',
guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider',
+2 -1
View File
@@ -709,12 +709,13 @@ test('#535 no built entry reaches the card by an address without a version', ()
'сам фасад остаётся стабильным входом Lovelace-ресурса и тянет хешированный чанк');
});
test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => {
test('bundle tree verification rejects orphan chunks (#353 AC4)', async (t) => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { verifyBundleTree, sha256Bytes } = await import('../scripts/bundle-tree.mjs');
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'houseplan-assets'));
const entryCode = 'try{await import("./houseplan-assets/main-abc.js")}catch(e){}';
const panelCode = 'try{await import("./houseplan-card.js")}catch(e){}';
+7 -6
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { acceptIdentical, acceptedDocsManifest, identicalDecision, identicalDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs';
@@ -213,8 +213,9 @@ test('#455 принятый манифест несёт среду приёмк
// ---------- #512: --identical ----------
const identicalRoot = (frames) => {
const identicalRoot = (t, frames) => {
const root = mkdtempSync(join(tmpdir(), 'hp-identical-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'docs', 'images'), { recursive: true });
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
@@ -241,8 +242,8 @@ const identicalRoot = (frames) => {
return { root, manifest, capture, compare };
};
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async () => {
const { root, manifest, capture, compare } = identicalRoot({});
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async (t) => {
const { root, manifest, capture, compare } = identicalRoot(t, {});
const log = [];
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
assert.equal(code, 0);
@@ -259,8 +260,8 @@ test('#512 AC3: identical frames accept only the source fingerprint; bytes and p
assert.match(log[0], /попиксельно совпали/);
});
test('#512 AC3: one differing pixel refuses, names the frame and leaves everything committed as it was', async () => {
const { root, manifest, capture, compare } = identicalRoot({ 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
test('#512 AC3: one differing pixel refuses, names the frame and leaves everything committed as it was', async (t) => {
const { root, manifest, capture, compare } = identicalRoot(t, { 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
const log = [];
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
assert.equal(code, 1);
+30 -25
View File
@@ -87,7 +87,11 @@ const HOST_TEST_ALLOWANCE = process.platform === 'linux'
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } = {}) {
// #646: каталог эталонов ≈ 20 МБ, и прежде он оставался в TMPDIR после
// каждого вызова — 433 копии за серию прогонов, 8,5 ГБ, ENOSPC. Всё, что
// тесту нужно из песочницы, читается здесь; наружу она не отдаётся.
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
try {
cpSync(BASELINES, sandbox, { recursive: true });
const env = { ...process.env };
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
@@ -102,19 +106,28 @@ function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } =
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
{ cwd: ROOT, env, encoding: 'utf8' });
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), sandbox };
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')) };
} catch (error) {
if (!expectFailure) throw error;
return {
error: String(error.stderr || error.message),
index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')),
sandbox,
};
}
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
}
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', () => {
const from = fixture({ platform: 'linux' });
/** Артефакт съёмки, который убирается по завершении теста, даже упавшего (#646). */
function artifact(t, options) {
const dir = fixture(options);
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', (t) => {
const from = artifact(t, { platform: 'linux' });
const { index } = accept(from);
assert.equal(index.schema, GOLDEN_INDEX_SCHEMA);
assert.equal(index.capturedOn, 'linux', 'платформа КАДРОВ — из отчёта');
@@ -124,61 +137,55 @@ test('#571 AC1: артефакт Linux принимается, обе сторо
assert.equal(index.localAttestation, null, 'CI source is not presented as local WSL');
assert.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null,
'не-Linux хост теста оставляет явный след осознанного обхода');
rmSync(from, { recursive: true, force: true });
});
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
}, () => {
const from = fixture({ platform: 'linux', captureEnv: {} });
}, (t) => {
const from = artifact(t, { platform: 'linux', captureEnv: {} });
assert.equal(existsSync(resolve(from, 'wsl-attestation.json')), false,
'обычный golden:capture не должен неявно получать WSL-аттестацию');
const before = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
const { error, index } = accept(from, { reason: '', expectFailure: true });
assert.match(error, /локальная Linux-съёмка не аттестована/);
assert.deepEqual(index, before, 'отказ обязан произойти до изменения эталонов и индекса');
rmSync(from, { recursive: true, force: true });
});
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => {
const from = fixture({ platform: 'win32' });
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', (t) => {
const from = artifact(t, { platform: 'win32' });
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
const { error, index } = accept(from, { reason: '', expectFailure: true });
assert.match(error, /приёмка отказана/);
assert.match(error, /win32/);
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
'индекс обязан остаться нетронутым: отказ до записи');
rmSync(from, { recursive: true, force: true });
});
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', () => {
const from = fixture({ platform: 'win32' });
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', (t) => {
const from = artifact(t, { platform: 'win32' });
const reason = 'аудит #571: проверяю ветку осознанного обхода';
const { index, stdout } = accept(from, { reason });
assert.equal(index.capturedOn, 'win32');
assert.equal(index.acceptedOn, process.platform);
assert.deepEqual(index.foreignCapture, { reason });
assert.match(stdout, /Чужая среда разрешена осознанно/);
rmSync(from, { recursive: true, force: true });
});
test('#571 схема 2 fail-closed: раздел capture обязателен', () => {
test('#571 схема 2 fail-closed: раздел capture обязателен', (t) => {
assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/);
assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/);
const from = fixture({ capture: { arch: 'x64', chromium: '1' } });
const from = artifact(t, { capture: { arch: 'x64', chromium: '1' } });
const { error } = accept(from, { expectFailure: true });
assert.match(error, /не называет платформу съёмки/);
rmSync(from, { recursive: true, force: true });
});
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', () => {
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', (t) => {
assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true });
const from = fixture({ schema: 1 });
const from = artifact(t, { schema: 1 });
const { index, stdout } = accept(from);
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
assert.equal(index.acceptedOn, process.platform);
assert.match(stdout, /Отчёт старой схемы/);
rmSync(from, { recursive: true, force: true });
});
test('#571 индекс любой схемы читается одним правилом', () => {
@@ -188,15 +195,13 @@ test('#571 индекс любой схемы читается одним пра
assert.equal(indexCapturedOn(null), null);
});
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', () => {
const tampered = fixture();
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', (t) => {
const tampered = artifact(t);
const victim = resolve(tampered, 'actual', `${GOLDEN_SCENARIOS[0].id}.png`);
writeFileSync(victim, Buffer.concat([readFileSync(victim), Buffer.from([0])]));
assert.match(accept(tampered, { expectFailure: true }).error, /candidate changed after capture/);
rmSync(tampered, { recursive: true, force: true });
const incomplete = fixture();
const incomplete = artifact(t);
rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`));
assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/);
rmSync(incomplete, { recursive: true, force: true });
});
+4 -2
View File
@@ -238,15 +238,17 @@ function runStepRebase(work) {
const to = body.indexOf('# #635 r2:');
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
try {
const output = join(temp, 'output');
writeFileSync(output, '');
const script = `${body.slice(from, to)}\necho REBASED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
});
const result = { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
} finally {
rmSync(temp, { recursive: true, force: true });
return result;
}
}
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
+266
View File
@@ -0,0 +1,266 @@
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: каталог уносит дочерний процесс
});`);
});