From af361f5dece0df54b669437b448367507907a384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Wed, 4 Nov 2026 12:12:30 -0300 Subject: [PATCH] fix: preserve barrels after consumer write failures During default barrel discovery, when writing a rewritten consumer fails, its original import stays on disk. The tracker marks that consumer as rewritten before the write, so cleanup can delete the barrel it still needs. Move that tracking step after rendering and writing succeed. The regression injects a write failure into a temporary project and verifies that the barrel survives, including when another consumer was rewritten successfully. Dry-run and check mode retain their existing predictions. All 186 tests, build, lint, formatting and installed-check passed on Node 24. The failure is injected at the filesystem call; the test does not depend on platform-specific file permissions. --- src/main.ts | 3 +- test/integration/write-failure.test.ts | 75 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/integration/write-failure.test.ts diff --git a/src/main.ts b/src/main.ts index b8376bc..3f5dda8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -181,8 +181,6 @@ async function processFiles(files: string[], ctx: Context): Promise { const rewrites = await buildRewrites(analysis, filePath, ctx); if (rewrites.size === 0) continue; - markRewrittenBarrels(analysis, filePath, rewrites, ctx); - const rewriteContext = { ...baseRewriteContext, singleQuote: detectSingleQuote(analysis.sourceFile) }; const content = applyRewrites(analysis.sourceFile, rewrites, filePath, rewriteContext); @@ -193,6 +191,7 @@ async function processFiles(files: string[], ctx: Context): Promise { if (ctx.write && !ctx.check) { await write(filePath, content); } + markRewrittenBarrels(analysis, filePath, rewrites, ctx); modified.push(filePath); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); diff --git a/test/integration/write-failure.test.ts b/test/integration/write-failure.test.ts new file mode 100644 index 0000000..223b621 --- /dev/null +++ b/test/integration/write-failure.test.ts @@ -0,0 +1,75 @@ +import { test, type TestContext } from "node:test"; +import assert from "node:assert/strict"; +import fs, { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unbarrelify } from "../../src/index.ts"; + +async function createProject(t: TestContext, consumers: string[]) { + const dir = await realpath(await mkdtemp(join(tmpdir(), "unbarrelify-write-failure-"))); + t.after(() => rm(dir, { recursive: true })); + const barrel = join(dir, "barrel.ts"); + const original = 'import { value } from "./barrel.ts";\nconsole.log(value);\n'; + await writeFile(join(dir, "value.ts"), "export const value = 42;\n"); + await writeFile(barrel, 'export { value } from "./value.ts";\n'); + for (const name of consumers) await writeFile(join(dir, name), original); + return { dir, barrel, original }; +} + +for (const consumers of [["failed.ts"], ["written.ts", "failed.ts"]]) { + test(`keeps a barrel when writing one of ${consumers.length} consumers fails`, async (t) => { + const { dir, barrel, original } = await createProject(t, consumers); + const failed = join(dir, "failed.ts"); + const originalWrite = fs.writeFile; + const writeError = Object.assign(new Error("simulated write failure"), { code: "EACCES" }); + let failedWrites = 0; + const errors = t.mock.method(console, "error", () => {}); + const writer = t.mock.method(fs, "writeFile", async (...args: Parameters) => { + if (args[0] === failed) { + failedWrites++; + throw writeError; + } + return originalWrite(...args); + }); + syncBuiltinESMExports(); + + try { + const result = await unbarrelify({ cwd: dir, files: ["*.ts"], ext: ".ts", write: true }); + + assert.equal(failedWrites, 1, "the failure must occur during the consumer write"); + assert.ok(errors.mock.calls.some((call) => call.arguments.includes(writeError.message))); + assert.equal(await readFile(failed, "utf-8"), original); + assert.ok(existsSync(barrel), "the unchanged consumer still needs its barrel"); + assert.ok(!result.deleted.includes(barrel)); + assert.ok(!result.modified.includes(failed)); + assert.ok(result.preserved.some((entry) => entry.path === barrel && entry.consumers.includes(failed))); + + if (consumers.includes("written.ts")) { + const written = join(dir, "written.ts"); + assert.ok(result.modified.includes(written)); + assert.ok((await readFile(written, "utf-8")).includes('from "./value.ts"')); + } + } finally { + writer.mock.restore(); + syncBuiltinESMExports(); + } + }); +} + +for (const options of [{ write: true }, { write: false }, { write: true, check: true }]) { + test(`retains successful rewrite behavior for ${JSON.stringify(options)}`, async (t) => { + const { dir, barrel, original } = await createProject(t, ["consumer.ts"]); + const consumer = join(dir, "consumer.ts"); + const result = await unbarrelify({ cwd: dir, files: ["*.ts"], ext: ".ts", ...options }); + + assert.deepEqual(result.modified, [consumer]); + assert.deepEqual(result.deleted, [barrel]); + const writes = options.write && !options.check; + assert.equal(existsSync(barrel), !writes); + const content = await readFile(consumer, "utf-8"); + if (writes) assert.ok(content.includes('from "./value.ts"')); + else assert.equal(content, original); + }); +}