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({