From 578dae56d9c3f6c743dd6fc9a4a8145c92123827 Mon Sep 17 00:00:00 2001 From: "Luis A. Ortega" Date: Tue, 22 Sep 2026 16:21:56 +0200 Subject: [PATCH] fix(files): limit target exclusions to scan root --- README.md | 2 + src/core/services/files/files.worker.ts | 28 +++++-- .../core/services/files/files.worker.test.ts | 77 ++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 314c5676..37828b5f 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ After pressing V to enter range selection mode: | --json-stream | Output results in streaming JSON format (one JSON object per line as results are found). Useful for real-time processing. | | -v, --version | Show npkill version | +When an exclusion matches a target name, such as `node_modules`, it excludes only that target directly under the starting directory. Use an absolute path to exclude a specific nested target. + ## Examples diff --git a/src/core/services/files/files.worker.ts b/src/core/services/files/files.worker.ts index e1ca1975..40ab0779 100644 --- a/src/core/services/files/files.worker.ts +++ b/src/core/services/files/files.worker.ts @@ -2,7 +2,7 @@ import { Dir, Dirent } from 'fs'; import { lstat, opendir, readdir } from 'fs/promises'; import EventEmitter from 'events'; import { WorkerMessage, WorkerScanOptions } from './files.worker.service.js'; -import { join } from 'path'; +import { basename, isAbsolute, join, normalize } from 'path'; import { MessagePort, parentPort } from 'node:worker_threads'; import { EVENTS, MAX_PROCS } from '../../../constants/workers.constants.js'; import { GLOBAL_IGNORE } from '../../constants/global-ignored.constants.js'; @@ -105,6 +105,8 @@ class FileWalker { targets: [''], exclude: [], }; + private readonly excludedTargets = new Set(); + private substringExclusions: string[] = []; private readonly taskQueue: Task[] = []; private completedTasks = 0; @@ -113,6 +115,22 @@ class FileWalker { setSearchConfig(params: WorkerScanOptions): void { this.searchConfig = params; + this.excludedTargets.clear(); + this.substringExclusions = []; + + for (const ex of params.exclude ?? []) { + const normalizedExclude = normalize(ex).replace(/[\\/]+$/, ''); + if (params.targets.includes(normalizedExclude)) { + this.excludedTargets.add(join(params.rootPath, normalizedExclude)); + } else if ( + isAbsolute(normalizedExclude) && + params.targets.includes(basename(normalizedExclude)) + ) { + this.excludedTargets.add(normalizedExclude); + } else { + this.substringExclusions.push(ex); + } + } } stop(): void { @@ -294,10 +312,10 @@ class FileWalker { } private isExcluded(path: string): boolean { - if (this.searchConfig.exclude == null) { - return false; - } - return this.searchConfig.exclude.some((ex) => path.includes(ex)); + return ( + this.excludedTargets.has(path) || + this.substringExclusions.some((ex) => path.includes(ex)) + ); } private isTargetFolder(path: string): boolean { diff --git a/tests/core/services/files/files.worker.test.ts b/tests/core/services/files/files.worker.test.ts index e9d78df4..8bc3f520 100644 --- a/tests/core/services/files/files.worker.test.ts +++ b/tests/core/services/files/files.worker.test.ts @@ -1,7 +1,7 @@ import { jest } from '@jest/globals'; import EventEmitter from 'node:events'; import { Dir } from 'node:fs'; -import { join, normalize } from 'node:path'; +import { join, normalize, resolve } from 'node:path'; import { MessageChannel, MessagePort } from 'node:worker_threads'; import { GLOBAL_IGNORE } from '../../../../src/core/constants/global-ignored.constants.js'; @@ -77,7 +77,7 @@ describe('FileWorker', () => { const setExploreConfig = (params: ScanOptions) => { tunnelEmitter.postMessage({ type: EVENTS.exploreConfig, - value: params, + value: { ...params, rootPath: basePath }, }); }; @@ -230,6 +230,79 @@ describe('FileWorker', () => { }); describe('should exclude dir', () => { + it('does not exclude a similarly named sibling for an absolute target path', (done) => { + const rootPath = resolve('scan-root'); + tunnelEmitter.postMessage({ + type: EVENTS.exploreConfig, + value: { + rootPath, + targets: [target], + exclude: [join(rootPath, target)], + }, + }); + dirEntriesMock = ['node_modules', 'node_modules-backup'].map((name) => ({ + name, + isDirectory: () => true, + isSymbolicLink: () => false, + })); + + tunnelEmitter.on('message', (message) => { + if (message.type === EVENTS.scanResult) { + expect(message.value.results).toEqual([ + { path: join(rootPath, 'node_modules-backup'), isTarget: false }, + ]); + done(); + } + }); + tunnelEmitter.postMessage({ + type: EVENTS.explore, + value: { path: rootPath }, + }); + }); + + ['node_modules', './node_modules'].forEach((excluded) => { + it(`excludes only the root target for ${excluded}`, (done) => { + setExploreConfig({ targets: [target], exclude: [excluded] }); + const directory = (name: string) => ({ + name, + isDirectory: () => true, + isSymbolicLink: () => false, + }); + dirEntriesMock = [directory('node_modules'), directory('project')]; + + let scans = 0; + tunnelEmitter.on('message', (message) => { + if (message.type !== EVENTS.scanResult) { + return; + } + + if (scans++ === 0) { + expect(message.value.results).toEqual([ + { path: join(basePath, 'project'), isTarget: false }, + ]); + dirEntriesMock = [directory('node_modules')]; + tunnelEmitter.postMessage({ + type: EVENTS.explore, + value: { path: join(basePath, 'project') }, + }); + } else { + expect(message.value.results).toEqual([ + { + path: join(basePath, 'project', 'node_modules'), + isTarget: true, + }, + ]); + done(); + } + }); + + tunnelEmitter.postMessage({ + type: EVENTS.explore, + value: { path: basePath }, + }); + }); + }); + it('when a simple patterns is gived', (done) => { const excluded = ['ignorethis', 'andignorethis']; setExploreConfig({