From b9e46c107ce91051431dbb12ec3a13f260ceed59 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:48:19 +0700 Subject: [PATCH 1/3] fix(clean): only list the output directory when cleaning it The default clean pattern is the absolute outDir path, so the glob had to read the parent directory to match it. Tools that fingerprint directory reads then saw every sibling of the output directory as a build input. Glob the contents of outDir directly when a clean entry is exactly the output directory, and keep the combined glob for other and negated patterns. --- src/features/clean.test.ts | 98 ++++++++++++++++++++++++++++++++++++++ src/features/clean.ts | 38 ++++++++++++--- 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 src/features/clean.test.ts diff --git a/src/features/clean.test.ts b/src/features/clean.test.ts new file mode 100644 index 000000000..aa5563d80 --- /dev/null +++ b/src/features/clean.test.ts @@ -0,0 +1,98 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createRequire, syncBuiltinESMExports } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { fsExists, fsRemove } from '../utils/fs.ts' +import { cleanOutDir, resolveClean } from './clean.ts' +import type { ResolvedConfig, UserConfig } from '../config/index.ts' + +const fs = createRequire(import.meta.url)('node:fs') as typeof import('node:fs') + +let cwd: string + +beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'tsdown-clean-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + syncBuiltinESMExports() + await fsRemove(cwd) +}) + +async function writeFiles(files: string[]) { + for (const file of files) { + const filePath = path.join(cwd, file) + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, '') + } +} + +async function clean(outDir: string, clean: UserConfig['clean']) { + const resolvedOutDir = path.resolve(cwd, outDir) + const config: Partial = { + cwd, + outDir: resolvedOutDir, + clean: resolveClean(clean, resolvedOutDir, cwd), + } + // Patch the CommonJS module and sync the ESM bindings so the spy sees the + // directory reads made inside tinyglobby + const readdir = vi.spyOn(fs, 'readdir') + syncBuiltinESMExports() + await cleanOutDir([config as ResolvedConfig]) + return readdir.mock.calls.map(([dir]) => + path.relative(cwd, String(dir)).replaceAll('\\', '/'), + ) +} + +describe('cleanOutDir', () => { + test('lists only the default output directory', async () => { + await writeFiles(['dist/old.js', 'dist/nested/old.js', 'sibling.txt']) + + const listed = await clean('dist', true) + + expect(listed.toSorted()).toEqual(['dist', 'dist/nested']) + expect(await fsExists(path.join(cwd, 'dist'))).toBe(true) + expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false) + expect(await fsExists(path.join(cwd, 'dist/nested'))).toBe(false) + expect(await fsExists(path.join(cwd, 'sibling.txt'))).toBe(true) + }) + + test('lists only a nested custom output directory', async () => { + await writeFiles(['out/lib/old.js', 'out/other.js']) + + const listed = await clean('out/lib', true) + + expect(listed).toEqual(['out/lib']) + expect(await fsExists(path.join(cwd, 'out/lib/old.js'))).toBe(false) + expect(await fsExists(path.join(cwd, 'out/other.js'))).toBe(true) + }) + + test('does not list anything outside a missing output directory', async () => { + const listed = await clean('dist', true) + + expect(listed).toEqual(['dist']) + }) + + test('still removes other custom patterns', async () => { + await writeFiles(['dist/old.js', 'temp/old.js', 'app.log', 'keep.js']) + + await clean('dist', ['dist', 'temp', '*.log']) + + expect(await fsExists(path.join(cwd, 'dist'))).toBe(true) + expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false) + expect(await fsExists(path.join(cwd, 'temp'))).toBe(false) + expect(await fsExists(path.join(cwd, 'app.log'))).toBe(false) + expect(await fsExists(path.join(cwd, 'keep.js'))).toBe(true) + }) + + test('respects negated patterns', async () => { + await writeFiles(['dist/old.js', 'dist/keep.js']) + + await clean('dist', ['dist', '!dist/keep.js']) + + expect(await fsExists(path.join(cwd, 'dist/old.js'))).toBe(false) + expect(await fsExists(path.join(cwd, 'dist/keep.js'))).toBe(true) + }) +}) diff --git a/src/features/clean.ts b/src/features/clean.ts index 5ab936b03..d83f218b6 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { createDebug } from 'obug' -import { glob } from 'tinyglobby' +import { glob, isDynamicPattern } from 'tinyglobby' import { fsRemove } from '../utils/fs.ts' import { slash } from '../utils/general.ts' import { globalLogger } from '../utils/logger.ts' @@ -25,12 +25,36 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { } if (!config.clean.length) continue - const files = await glob(config.clean, { - cwd: config.cwd, - absolute: true, - onlyFiles: false, - dot: true, - }) + + // Matching a literal `outDir` pattern from `cwd` makes the glob list `cwd` + // itself, so list only the contents of `outDir` instead. Negated patterns + // can exclude files inside `outDir`, so keep the combined glob for them. + const outDir = path.resolve(config.outDir) + const scopeToOutDir = config.clean.every( + (pattern) => !pattern.startsWith('!'), + ) + let cleansOutDir = false + const patterns: string[] = [] + for (const pattern of config.clean) { + if ( + scopeToOutDir && + !isDynamicPattern(pattern) && + path.resolve(config.cwd, pattern) === outDir + ) { + cleansOutDir = true + } else { + patterns.push(pattern) + } + } + + const globOptions = { absolute: true, onlyFiles: false, dot: true } + const files: string[] = [] + if (cleansOutDir) { + files.push(...(await glob('**', { ...globOptions, cwd: outDir }))) + } + if (patterns.length) { + files.push(...(await glob(patterns, { ...globOptions, cwd: config.cwd }))) + } const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, '') for (const file of files) { From eeffc60a1061d4929e6568e52232735ddd5588f7 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:53:26 +0700 Subject: [PATCH 2/3] fix(clean): compare outDir with glob results using forward slashes On Windows the glob returns forward slashes and outDir has backslashes, so the output directory itself was never skipped and got removed, along with anything a negated pattern meant to keep. --- src/features/clean.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/clean.ts b/src/features/clean.ts index d83f218b6..66e285c30 100644 --- a/src/features/clean.ts +++ b/src/features/clean.ts @@ -56,9 +56,10 @@ export async function cleanOutDir(configs: ResolvedConfig[]): Promise { files.push(...(await glob(patterns, { ...globOptions, cwd: config.cwd }))) } - const normalizedOutDir = config.outDir.replace(RE_LAST_SLASH, '') + // Glob results use forward slashes, so compare them in the same form + const normalizedOutDir = slash(config.outDir).replace(RE_LAST_SLASH, '') for (const file of files) { - const normalizedFile = file.replace(RE_LAST_SLASH, '') + const normalizedFile = slash(file).replace(RE_LAST_SLASH, '') if (normalizedFile !== normalizedOutDir) { removes.add(file) } From 46cacd570c8ed72441c87458d9bb796b41d3b27f Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:56:34 +0700 Subject: [PATCH 3/3] test(clean): import fs directly --- src/features/clean.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/clean.test.ts b/src/features/clean.test.ts index aa5563d80..badc04aa0 100644 --- a/src/features/clean.test.ts +++ b/src/features/clean.test.ts @@ -1,5 +1,6 @@ +import fs from 'node:fs' import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' -import { createRequire, syncBuiltinESMExports } from 'node:module' +import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -7,8 +8,6 @@ import { fsExists, fsRemove } from '../utils/fs.ts' import { cleanOutDir, resolveClean } from './clean.ts' import type { ResolvedConfig, UserConfig } from '../config/index.ts' -const fs = createRequire(import.meta.url)('node:fs') as typeof import('node:fs') - let cwd: string beforeEach(async () => {