From e555e87cf5a599b302768bbebbbb3425b9500d12 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 24 Aug 2026 13:54:58 +0800 Subject: [PATCH 1/2] perf(fmt): batch config ignore matching during discovery --- packages/rstack/src/fmt/discoverPaths.ts | 306 +++++++++++++----- packages/rstack/src/fmt/discovery.ts | 13 +- packages/rstack/src/fmt/ignore.ts | 82 +++-- .../rstack/tests/fmt/discoverPaths.test.ts | 96 ++++++ packages/rstack/tests/fmt/discovery.test.ts | 102 ++++-- 5 files changed, 479 insertions(+), 120 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 32e94431..9381d275 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -5,6 +5,11 @@ import micromatch from 'micromatch'; import readdir, { type Dirent, type DirentLike } from 'tiny-readdir'; import type { GitIgnoreMatcher as NativeGitIgnoreMatcher } from '../../binding.cjs'; import { loadNativeBinding } from '../native/index.ts'; +import type { + BatchIgnoreContext, + IgnoreMatcher, + IgnorePredicate, +} from './ignore.ts'; import { createRelativePathResolver, toPosixPath, @@ -21,8 +26,18 @@ const defaultIgnoredDirNames = new Set([ 'node_modules', ]); -const gitIgnored = Symbol('gitIgnored'); -type GitIgnoreDirent = Dirent & { [gitIgnored]?: true }; +const ignored = Symbol('ignored'); +type IgnoredDirent = Dirent & { [ignored]?: true }; +type TraversalIgnorePredicate = (( + filePath: string, + isDirectory: boolean, +) => boolean) & + Pick; + +interface GitIgnoreBatchContext { + readonly matcher: NativeGitIgnoreMatcher; + readonly relativeParent: string; +} interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ @@ -31,7 +46,7 @@ interface DiscoverFmtPathsOptions { /** Whether files inside node_modules may be discovered. */ withNodeModules?: boolean; /** Returns whether a candidate path should be excluded. */ - isIgnored?: (filePath: string, isDirectory: boolean) => boolean; + isIgnored?: TraversalIgnorePredicate; } const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => @@ -141,55 +156,20 @@ class GitIgnoreFiles { return this.#matcher!.isIgnored(toPosixPath(relativePath), isDirectory); } - /** Matches one directory's entries in a single native call. */ - matchDirents( - parentPath: string, - dirents: Dirent[], - ): boolean | number | Uint8Array | undefined { - if (!this.#hasRules || dirents.length === 0) { + resolveBatchContext(parentPath: string): GitIgnoreBatchContext | undefined { + if (!this.#hasRules) { return; } - const relativeParentPath = this.#resolveRelativePath(parentPath); - if (!isRelativePathInside(relativeParentPath)) { + const relativeParent = this.#resolveRelativePath(parentPath); + if (!isRelativePathInside(relativeParent)) { return; } - const relativeParent = toPosixPath(relativeParentPath); - - if (dirents.length === 1) { - const dirent = dirents[0]; - return this.#matcher!.isIgnoredChild( - relativeParent, - dirent.name, - dirent.isDirectory(), - ); - } - - const names = new Array(dirents.length); - - if (dirents.length <= 32) { - let directoryMask = 0; - for (let index = 0; index < dirents.length; index++) { - const dirent = dirents[index]; - names[index] = dirent.name; - directoryMask |= Number(dirent.isDirectory()) << index; - } - return this.#matcher!.isIgnoredBatchMask( - relativeParent, - names, - directoryMask >>> 0, - ); - } - - const directoryFlags = new Uint8Array(dirents.length); - for (let index = 0; index < dirents.length; index++) { - const dirent = dirents[index]; - names[index] = dirent.name; - directoryFlags[index] = Number(dirent.isDirectory()); - } - - return this.#matcher!.isIgnoredBatch(relativeParent, names, directoryFlags); + return { + matcher: this.#matcher!, + relativeParent: toPosixPath(relativeParent), + }; } #load(directoryPath: string): Promise { @@ -218,29 +198,219 @@ class GitIgnoreFiles { } } +const isIgnoredBeforeNative = ( + parentPath: string, + dirent: Dirent, + ignoredDirNames: ReadonlySet, + isIncluded: ((filePath: string) => boolean) | undefined, + precheck: IgnorePredicate | undefined, +): boolean => { + if (ignoredDirNames.has(dirent.name)) { + return true; + } + + const isDirectory = dirent.isDirectory(); + let targetPath: string | undefined; + if (!isDirectory && isIncluded) { + targetPath = path.join(parentPath, dirent.name); + if (!isIncluded(targetPath)) { + return true; + } + } + if (!isDirectory && isBinaryPath(dirent.name)) { + return true; + } + + return ( + precheck?.( + targetPath ?? path.join(parentPath, dirent.name), + isDirectory, + ) === true + ); +}; + +/** Matches one directory after earlier traversal rules have removed candidates. */ +const markIgnoredDirents = ( + parentPath: string, + dirents: Dirent[], + gitIgnore: GitIgnoreFiles, + ignoredDirNames: ReadonlySet, + isIncluded: ((filePath: string) => boolean) | undefined, + batchIgnore?: BatchIgnoreContext, +): void => { + const gitIgnoreContext = gitIgnore.resolveBatchContext(parentPath); + + if (dirents.length === 1) { + const dirent = dirents[0]; + if ( + gitIgnoreContext?.matcher.isIgnoredChild( + gitIgnoreContext.relativeParent, + dirent.name, + dirent.isDirectory(), + ) === true || + isIgnoredBeforeNative( + parentPath, + dirent, + ignoredDirNames, + isIncluded, + batchIgnore?.precheck, + ) || + batchIgnore?.matcher.isIgnoredChild( + parentPath, + dirent.name, + dirent.isDirectory(), + ) === true + ) { + (dirent as IgnoredDirent)[ignored] = true; + } + return; + } + + const names = new Array(dirents.length); + + if (dirents.length <= 32) { + let directoryMask = 0; + for (let index = 0; index < dirents.length; index++) { + const dirent = dirents[index]; + names[index] = dirent.name; + directoryMask |= Number(dirent.isDirectory()) << index; + } + + let ignoredMask = gitIgnoreContext + ? gitIgnoreContext.matcher.isIgnoredBatchMask( + gitIgnoreContext.relativeParent, + names, + directoryMask >>> 0, + ) + : 0; + + if (batchIgnore) { + for (let index = 0; index < dirents.length; index++) { + const entryMask = 1 << index; + if ( + (ignoredMask & entryMask) === 0 && + isIgnoredBeforeNative( + parentPath, + dirents[index], + ignoredDirNames, + isIncluded, + batchIgnore.precheck, + ) + ) { + ignoredMask |= entryMask; + } + } + + const validMask = 0xffffffff >>> (32 - dirents.length); + const candidateMask = (validMask & ~ignoredMask) >>> 0; + if (candidateMask !== 0) { + ignoredMask = + (ignoredMask | + batchIgnore.matcher.isIgnoredBatchMask( + parentPath, + names, + directoryMask >>> 0, + candidateMask, + )) >>> + 0; + } + } + + for (let index = 0; index < dirents.length; index++) { + if ((ignoredMask & (1 << index)) !== 0) { + (dirents[index] as IgnoredDirent)[ignored] = true; + } + } + return; + } + + const directoryFlags = new Uint8Array(dirents.length); + for (let index = 0; index < dirents.length; index++) { + const dirent = dirents[index]; + names[index] = dirent.name; + directoryFlags[index] = Number(dirent.isDirectory()); + } + + const ignoredFlags = gitIgnoreContext + ? gitIgnoreContext.matcher.isIgnoredBatch( + gitIgnoreContext.relativeParent, + names, + directoryFlags, + ) + : new Uint8Array(dirents.length); + + if (batchIgnore) { + const candidateFlags = new Uint8Array(dirents.length); + let candidateCount = 0; + for (let index = 0; index < dirents.length; index++) { + if (ignoredFlags[index] === 0) { + if ( + isIgnoredBeforeNative( + parentPath, + dirents[index], + ignoredDirNames, + isIncluded, + batchIgnore.precheck, + ) + ) { + ignoredFlags[index] = 1; + } else { + candidateFlags[index] = 1; + candidateCount++; + } + } + } + + if (candidateCount !== 0) { + const nextIgnored = batchIgnore.matcher.isIgnoredBatch( + parentPath, + names, + directoryFlags, + candidateFlags, + ); + for (let index = 0; index < dirents.length; index++) { + ignoredFlags[index] |= nextIgnored[index]; + } + } + } + + for (let index = 0; index < dirents.length; index++) { + if (ignoredFlags[index] !== 0) { + (dirents[index] as IgnoredDirent)[ignored] = true; + } + } +}; + const createTraversalOptions = ( gitIgnore: GitIgnoreFiles, ignoredDirNames: ReadonlySet, signal: { aborted: boolean }, onError: (error: unknown) => void, isIncluded?: (filePath: string) => boolean, - isIgnored?: (filePath: string, isDirectory: boolean) => boolean, + isIgnored?: TraversalIgnorePredicate, ) => { + const batchIgnore = isIgnored?.batch; + const scalarIgnore = batchIgnore ? undefined : isIgnored; + return { followSymlinks: false, signal, ignore: (targetPath: string, targetContext: DirentLike) => { // With symlink following disabled, tiny-readdir always provides a Dirent here. const dirent = targetContext as Dirent; - if (ignoredDirNames.has(dirent.name)) { + if ( + (dirent as IgnoredDirent)[ignored] === true || + ignoredDirNames.has(dirent.name) + ) { return true; } + if (batchIgnore) { + return false; + } + if (dirent.isDirectory()) { - return ( - (dirent as GitIgnoreDirent)[gitIgnored] === true || - isIgnored?.(targetPath, true) === true - ); + return scalarIgnore?.(targetPath, true) === true; } if (isIncluded !== undefined && !isIncluded(targetPath)) { @@ -248,9 +418,7 @@ const createTraversalOptions = ( } return ( - isIgnored?.(targetPath, false) === true || - isBinaryPath(targetPath) || - (dirent as GitIgnoreDirent)[gitIgnored] === true + scalarIgnore?.(targetPath, false) === true || isBinaryPath(targetPath) ); }, onDirents: async (dirents: Dirent[]) => { @@ -268,24 +436,14 @@ const createTraversalOptions = ( await gitIgnore.load(parentPath); } - const ignored = gitIgnore.matchDirents(parentPath, dirents); - if (typeof ignored === 'boolean') { - if (ignored) { - (dirents[0] as GitIgnoreDirent)[gitIgnored] = true; - } - } else if (typeof ignored === 'number') { - for (let index = 0; index < dirents.length; index++) { - if (ignored & (1 << index)) { - (dirents[index] as GitIgnoreDirent)[gitIgnored] = true; - } - } - } else if (ignored) { - for (let index = 0; index < ignored.length; index++) { - if (ignored[index] === 1) { - (dirents[index] as GitIgnoreDirent)[gitIgnored] = true; - } - } - } + markIgnoredDirents( + parentPath, + dirents, + gitIgnore, + ignoredDirNames, + isIncluded, + batchIgnore, + ); } catch (error) { onError(error); } @@ -300,7 +458,7 @@ const discoverDirectoryFiles = async ( gitIgnore: GitIgnoreFiles, ignoredDirNames: ReadonlySet, isIncluded?: (filePath: string) => boolean, - isIgnored?: (filePath: string, isDirectory: boolean) => boolean, + isIgnored?: TraversalIgnorePredicate, ): Promise => { let failed = false; let failure: unknown; diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 8e238de1..5e738a25 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -18,19 +18,20 @@ const discoverFmtFiles = async ({ withNodeModules, config, }: DiscoverFmtFilesOptions): Promise => { - const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); const isExcluded = excludedDirPath ? createDirMatcher(excludedDirPath) : undefined; - const shouldIgnore = isExcluded - ? (filePath: string, isDirectory = false) => - isExcluded(filePath) || isIgnored(filePath, isDirectory) - : isIgnored; + const isIgnored = await createIgnoreMatcher({ + config, + cwd, + ignorePaths, + precheck: isExcluded, + }); const filePaths = await discoverFmtPaths({ cwd, patterns, withNodeModules, - isIgnored: shouldIgnore, + isIgnored, }); if (filePaths.length === 0) { return []; diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 48e52105..4eb58003 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -1,6 +1,9 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import type { IgnoreSource } from '../../binding.cjs'; +import type { + IgnoreMatcher as NativeIgnoreMatcher, + IgnoreSource, +} from '../../binding.cjs'; import { loadNativeBinding } from '../native/index.ts'; import type { ResolvedFmtConfig } from './types.ts'; @@ -14,23 +17,59 @@ const defaultIgnoreNames = ['package-lock.json', 'pnpm-lock.yaml']; type IgnorePredicate = (filePath: string, isDirectory?: boolean) => boolean; +type BatchIgnoreMatcher = Pick< + NativeIgnoreMatcher, + 'isIgnoredBatch' | 'isIgnoredBatchMask' | 'isIgnoredChild' +>; + +interface BatchIgnoreContext { + readonly matcher: BatchIgnoreMatcher; + /** Cheap JavaScript checks applied before crossing into the native matcher. */ + readonly precheck?: IgnorePredicate; +} + +type IgnoreMatcher = IgnorePredicate & { + readonly batch?: BatchIgnoreContext; +}; + interface CreateIgnoreMatcherOptions { config: ResolvedFmtConfig; /** Base directory for relative ignore paths. */ cwd: string; ignorePaths?: string[]; + precheck?: IgnorePredicate; } -const createDefaultMatcher = (): IgnorePredicate => { +const createDefaultMatcher = (): IgnoreMatcher => { const suffixes = defaultIgnoreNames.map((name) => `${path.sep}${name}`); return (filePath) => suffixes.some((suffix) => filePath.endsWith(suffix)); }; -const createSourceMatcher = (sources: IgnoreSource[]): IgnorePredicate => { - const matcher = new (loadNativeBinding().IgnoreMatcher)(sources); +const combineIgnorePredicates = ( + first: IgnorePredicate, + second: IgnorePredicate, +): IgnorePredicate => { return (filePath, isDirectory = false) => - matcher.isIgnored(filePath, isDirectory); + first(filePath, isDirectory) || second(filePath, isDirectory); +}; + +const createSourceMatcher = ( + sources: IgnoreSource[], + precheck?: IgnorePredicate, +): IgnoreMatcher => { + const matcher = new (loadNativeBinding().IgnoreMatcher)(sources); + const isIgnored: IgnorePredicate = precheck + ? (filePath, isDirectory = false) => + precheck(filePath, isDirectory) || + matcher.isIgnored(filePath, isDirectory) + : (filePath, isDirectory = false) => + matcher.isIgnored(filePath, isDirectory); + + return Object.assign( + isIgnored, + precheck ? { batch: { matcher, precheck } } : { batch: { matcher } }, + ); }; const loadIgnoreSource = async ( @@ -59,29 +98,36 @@ const createIgnoreMatcher = async ({ config, cwd, ignorePaths = [], -}: CreateIgnoreMatcherOptions): Promise => { + precheck, +}: CreateIgnoreMatcherOptions): Promise => { const ignoreFileSources = await Promise.all( ignorePaths.map((ignorePath) => loadIgnoreSource(cwd, ignorePath)), ); if (config.ignorePatterns.length) { - return createSourceMatcher([ - { - rootPath: config.rootPath, - patterns: [...defaultIgnoreNames, ...config.ignorePatterns].join('\n'), - }, - ...ignoreFileSources, - ]); + return createSourceMatcher( + [ + { + rootPath: config.rootPath, + patterns: [...defaultIgnoreNames, ...config.ignorePatterns].join( + '\n', + ), + }, + ...ignoreFileSources, + ], + precheck, + ); } const defaultMatcher = createDefaultMatcher(); + const matcher = precheck + ? combineIgnorePredicates(precheck, defaultMatcher) + : defaultMatcher; if (ignoreFileSources.length === 0) { - return defaultMatcher; + return matcher; } - const cliMatcher = createSourceMatcher(ignoreFileSources); - return (filePath, isDirectory = false) => - defaultMatcher(filePath, isDirectory) || cliMatcher(filePath, isDirectory); + return createSourceMatcher(ignoreFileSources, matcher); }; export { createIgnoreMatcher }; -export type { IgnorePredicate }; +export type { BatchIgnoreContext, IgnoreMatcher, IgnorePredicate }; diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index 6cad49f9..7de788c1 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -321,6 +321,102 @@ test('applies an external ignore matcher to traversed and explicit paths', async }); }); +test('batches external ignore matching after gitignore short-circuiting', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.gitignore', 'git-ignored.ts\n'); + writeProjectFile(rootPath, 'config-ignored.ts'); + writeProjectFile(rootPath, 'git-ignored.ts'); + writeProjectFile(rootPath, 'image.png'); + writeProjectFile(rootPath, 'not-included.js'); + writeProjectFile(rootPath, 'visible.ts'); + + const scalarIgnore = rs.fn(() => false); + const maskIgnore = rs.fn( + ( + _parentPath: string, + names: string[], + _directoryMask: number, + candidateMask: number, + ): number => { + const index = names.indexOf('config-ignored.ts'); + return candidateMask & (1 << index); + }, + ); + const isIgnored = Object.assign(scalarIgnore, { + batch: { + matcher: { + isIgnoredBatch: rs.fn(() => new Uint8Array()), + isIgnoredBatchMask: maskIgnore, + isIgnoredChild: rs.fn(() => false), + }, + }, + }); + + const files = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['*.ts'], + isIgnored, + }); + + expect(relativePaths(rootPath, files)).toEqual(['visible.ts']); + expect(scalarIgnore).toHaveBeenCalledTimes(1); + expect(scalarIgnore).toHaveBeenCalledWith(rootPath, true); + expect(maskIgnore).toHaveBeenCalledTimes(1); + + const names = maskIgnore.mock.calls[0][1]; + const candidateMask = maskIgnore.mock.calls[0][3]; + expect(candidateMask & (1 << names.indexOf('.git'))).toBe(0); + expect(candidateMask & (1 << names.indexOf('git-ignored.ts'))).toBe(0); + expect(candidateMask & (1 << names.indexOf('image.png'))).toBe(0); + expect(candidateMask & (1 << names.indexOf('not-included.js'))).toBe(0); + expect(candidateMask & (1 << names.indexOf('visible.ts'))).not.toBe(0); + }); +}); + +test('uses array batches for directories with more than 32 entries', async () => { + await withTempProject(async (rootPath) => { + for (let index = 0; index < 33; index++) { + writeProjectFile(rootPath, `${index}.ts`); + } + + const arrayIgnore = rs.fn( + ( + _parentPath: string, + names: string[], + _directoryFlags: Uint8Array, + candidateFlags: Uint8Array, + ): Uint8Array => { + const ignored = new Uint8Array(names.length); + const index = names.indexOf('32.ts'); + ignored[index] = candidateFlags[index]; + return ignored; + }, + ); + const isIgnored = Object.assign( + rs.fn(() => false), + { + batch: { + matcher: { + isIgnoredBatch: arrayIgnore, + isIgnoredBatchMask: rs.fn(() => 0), + isIgnoredChild: rs.fn(() => false), + }, + }, + }, + ); + + const files = await discoverFmtPaths({ cwd: rootPath, isIgnored }); + + expect(files).toHaveLength(32); + expect(files).not.toContain(path.join(rootPath, '32.ts')); + expect(arrayIgnore).toHaveBeenCalledTimes(1); + const names = arrayIgnore.mock.calls[0][1]; + const candidateFlags = arrayIgnore.mock.calls[0][3]; + expect(candidateFlags[names.indexOf('.git')]).toBe(0); + expect(candidateFlags[names.indexOf('0.ts')]).toBe(1); + }); +}); + test.runIf(process.platform !== 'win32')( 'does not follow file or directory symlinks', async () => { diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index 0e062435..e55635e2 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -1,8 +1,9 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; -import { expect, test } from 'rstack/test'; +import { expect, rs, test } from 'rstack/test'; import { normalizeFmtConfig } from '../../src/fmt/config.ts'; import { discoverFmtFiles } from '../../src/fmt/discovery.ts'; +import { loadNativeBinding } from '../../src/native/index.ts'; import type { FmtConfig } from '../../src/fmt/types.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -47,6 +48,48 @@ test('applies config ignore patterns to discovered and explicit files', async () }); }); +test('uses the native batch matcher during directory discovery', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, 'generated/blocked.ts'); + writeProjectFile(rootPath, 'generated/keep.ts'); + writeProjectFile(rootPath, 'single/blocked.ts'); + writeProjectFile(rootPath, 'src/index.ts'); + const NativeIgnoreMatcher = loadNativeBinding().IgnoreMatcher; + const batchMatch = rs.spyOn( + NativeIgnoreMatcher.prototype, + 'isIgnoredBatchMask', + ); + const childMatch = rs.spyOn( + NativeIgnoreMatcher.prototype, + 'isIgnoredChild', + ); + const scalarMatch = rs.spyOn(NativeIgnoreMatcher.prototype, 'isIgnored'); + + try { + const files = await discover(rootPath, undefined, { + ignorePatterns: ['generated/blocked.ts', 'single/blocked.ts'], + }); + + expect(relativePaths(rootPath, files)).toEqual([ + path.join('generated', 'keep.ts'), + path.join('src', 'index.ts'), + ]); + expect(batchMatch).toHaveBeenCalled(); + expect(childMatch).toHaveBeenCalledWith( + path.join(rootPath, 'single'), + 'blocked.ts', + false, + ); + expect(scalarMatch).toHaveBeenCalledTimes(1); + expect(scalarMatch).toHaveBeenCalledWith(rootPath, true); + } finally { + batchMatch.mockRestore(); + childMatch.mockRestore(); + scalarMatch.mockRestore(); + } + }); +}); + test('applies config ignore patterns outside the config root', async () => { await withTempProject(async (rootPath) => { const configRoot = path.join(rootPath, 'project'); @@ -81,29 +124,44 @@ test('excludes .rstack from discovery', async () => { }); }); -test('excludes a custom cache directory', async () => { - await withTempProject(async (rootPath) => { - const cacheDir = path.join(rootPath, 'custom-cache'); - const cacheFile = writeProjectFile(rootPath, 'custom-cache/v1.json', '{}'); - writeProjectFile(rootPath, 'custom-cache/nested/ignored.ts'); - writeProjectFile(rootPath, 'index.ts'); - - const discoveredFiles = await discoverFmtFiles({ - cwd: rootPath, - excludedDirPath: cacheDir, - config: normalizeFmtConfig(undefined, rootPath), - }); - const explicitFile = await discoverFmtFiles({ - cwd: rootPath, - excludedDirPath: cacheDir, - patterns: [cacheFile], - config: normalizeFmtConfig(undefined, rootPath), +const customCacheCases: { config?: FmtConfig; name: string }[] = [ + { name: 'with scalar matching' }, + { + name: 'before native matching', + config: { ignorePatterns: ['generated/'] }, + }, +]; + +for (const { config, name } of customCacheCases) { + test(`excludes a custom cache directory ${name}`, async () => { + await withTempProject(async (rootPath) => { + const cacheDir = path.join(rootPath, 'custom-cache'); + const cacheFile = writeProjectFile( + rootPath, + 'custom-cache/v1.json', + '{}', + ); + writeProjectFile(rootPath, 'custom-cache/nested/ignored.ts'); + writeProjectFile(rootPath, 'index.ts'); + const normalizedConfig = normalizeFmtConfig(config, rootPath); + + const discoveredFiles = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + config: normalizedConfig, + }); + const explicitFile = await discoverFmtFiles({ + cwd: rootPath, + excludedDirPath: cacheDir, + patterns: [cacheFile], + config: normalizedConfig, + }); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']); + expect(explicitFile).toEqual([]); }); - - expect(relativePaths(rootPath, discoveredFiles)).toEqual(['index.ts']); - expect(explicitFile).toEqual([]); }); -}); +} test('keeps files re-included by a CLI ignore file during directory traversal', async () => { await withTempProject(async (rootPath) => { From de6695ba02a2f461e0f5654b10fb2191adb64cf1 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 24 Aug 2026 14:06:11 +0800 Subject: [PATCH 2/2] chore: add precheck to spellcheck dictionary --- scripts/dictionary.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 36841a6a..8c07ec80 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -19,6 +19,7 @@ noformat noprettier nosystem oxfmt +precheck quasis rsbuild rslib