From fa91f3a7311145e9c3f6e2d22d55aca07f844ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 03:29:21 +0300 Subject: [PATCH] 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: `. Two mutants witness the lint. Issue: #646 User-Visible: no --- scripts/mutation-registry.mjs | 22 ++ test/bundle-assets.test.mjs | 3 +- test/docs-accept.test.mjs | 13 +- test/golden-capture-provenance.test.mjs | 93 +++++---- test/rebase-generated.test.mjs | 20 +- test/temp-dir-hygiene.test.mjs | 266 ++++++++++++++++++++++++ 6 files changed, 357 insertions(+), 60 deletions(-) create mode 100644 test/temp-dir-hygiene.test.mjs diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 91b6f0e4..c55c7231 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/test/bundle-assets.test.mjs b/test/bundle-assets.test.mjs index d5455860..4321822f 100644 --- a/test/bundle-assets.test.mjs +++ b/test/bundle-assets.test.mjs @@ -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){}'; diff --git a/test/docs-accept.test.mjs b/test/docs-accept.test.mjs index 1a2301c8..1aeceb04 100644 --- a/test/docs-accept.test.mjs +++ b/test/docs-accept.test.mjs @@ -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); diff --git a/test/golden-capture-provenance.test.mjs b/test/golden-capture-provenance.test.mjs index 140f03cd..084b4f5e 100644 --- a/test/golden-capture-provenance.test.mjs +++ b/test/golden-capture-provenance.test.mjs @@ -87,34 +87,47 @@ 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-')); - cpSync(BASELINES, sandbox, { recursive: true }); - const env = { ...process.env }; - if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE; try { - // Сцены без эталона объявляются явно: `--expect-new` — это утверждение - // «я посмотрел на новый кадр», и обойти его фикстура не должна. - const fresh = GOLDEN_SCENARIOS - .filter((scenario) => !existsSync(resolve(BASELINES, `${scenario.id}.png`))) - .map((scenario) => scenario.id); - const stdout = execFileSync(process.execPath, - [resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`, - ...(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 }; - } catch (error) { - if (!expectFailure) throw error; - return { - error: String(error.stderr || error.message), - index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), - sandbox, - }; + cpSync(BASELINES, sandbox, { recursive: true }); + const env = { ...process.env }; + if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE; + try { + // Сцены без эталона объявляются явно: `--expect-new` — это утверждение + // «я посмотрел на новый кадр», и обойти его фикстура не должна. + const fresh = GOLDEN_SCENARIOS + .filter((scenario) => !existsSync(resolve(BASELINES, `${scenario.id}.png`))) + .map((scenario) => scenario.id); + const stdout = execFileSync(process.execPath, + [resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`, + ...(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')) }; + } catch (error) { + if (!expectFailure) throw error; + return { + error: String(error.stderr || error.message), + index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), + }; + } + } 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 }); }); diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index fc145a38..09f06aea 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -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-')); - 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') }; - rmSync(temp, { recursive: true, force: true }); - return result; + 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' }, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') }; + } finally { + rmSync(temp, { recursive: true, force: true }); + } } const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 diff --git a/test/temp-dir-hygiene.test.mjs b/test/temp-dir-hygiene.test.mjs new file mode 100644 index 00000000..6b60867f --- /dev/null +++ b/test/temp-dir-hygiene.test.mjs @@ -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: каталог уносит дочерний процесс + });`); +});