From 7d8ad951cf4247b5e65433abe57e9ec09cee24db Mon Sep 17 00:00:00 2001 From: Tony Gentilcore Date: Sun, 13 Sep 2026 22:20:05 -0700 Subject: [PATCH 1/3] perf(ban-dependencies): index replacement mappings --- src/rules/ban-dependencies.test.ts | 67 ++++++++++++++++++++++++++++++ src/rules/ban-dependencies.ts | 50 ++++++++++++++++++---- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/rules/ban-dependencies.test.ts b/src/rules/ban-dependencies.test.ts index 3c86afb..1a75272 100644 --- a/src/rules/ban-dependencies.test.ts +++ b/src/rules/ban-dependencies.test.ts @@ -31,6 +31,9 @@ ruleTester.run('ban-dependencies', banDependencies, { { code: `import foo from 'unknown-module';` }, + { + code: `import foo from 'unknown-module/subpath';` + }, { code: `const foo = require('unknown-module');` }, @@ -63,6 +66,15 @@ ruleTester.run('ban-dependencies', banDependencies, { } ] }, + { + code: `import foo from 'is-nan/subpath';`, + options: [ + { + presets: ['native'], + allowed: ['is-nan'] + } + ] + }, { code: `import foo from 'oogabooga';`, options: [ @@ -138,6 +150,21 @@ ruleTester.run('ban-dependencies', banDependencies, { } ] }, + { + code: `import foo from 'is-number/subpath';`, + errors: [ + { + line: 1, + column: 1, + messageId: 'simpleReplacement', + data: { + name: 'is-number', + description: + 'You can check if a value is a number by using `typeof` or coercing it to a number and using `Number.isFinite`.' + } + } + ] + }, { code: `const foo = await import('is-number');`, errors: [ @@ -224,6 +251,46 @@ ruleTester.run('ban-dependencies', banDependencies, { } ] }, + { + code: `import foo from 'oogabooga/subpath/deep';`, + options: [ + { + modules: ['oogabooga', 'oogabooga/subpath'] + } + ], + errors: [ + { + line: 1, + column: 1, + messageId: 'removalReplacement', + data: { + name: 'oogabooga', + description: + 'This module is disallowed and should be replaced with an alternative.' + } + } + ] + }, + { + code: `import foo from 'oogabooga/subpath/deep';`, + options: [ + { + modules: ['oogabooga/subpath', 'oogabooga'] + } + ], + errors: [ + { + line: 1, + column: 1, + messageId: 'removalReplacement', + data: { + name: 'oogabooga/subpath', + description: + 'This module is disallowed and should be replaced with an alternative.' + } + } + ] + }, { code: `import foo from 'object-is';`, options: [ diff --git a/src/rules/ban-dependencies.ts b/src/rules/ban-dependencies.ts index b237eb0..46eedf7 100644 --- a/src/rules/ban-dependencies.ts +++ b/src/rules/ban-dependencies.ts @@ -20,10 +20,25 @@ interface BanDependenciesOptions { allowed?: string[]; } -const availablePresets: Record = { - microutilities: microUtilsReplacements, - native: nativeReplacements, - preferred: preferredReplacements +interface PreparedManifest { + mappings: Array<[string, ModuleReplacementMapping]>; + moduleNames: Set; + replacements: ManifestModule['replacements']; +} + +function prepareManifest(manifest: ManifestModule): PreparedManifest { + const mappings = Object.entries(manifest.mappings); + return { + mappings, + moduleNames: new Set(mappings.map(([moduleName]) => moduleName)), + replacements: manifest.replacements + }; +} + +const availablePresets: Record = { + microutilities: prepareManifest(microUtilsReplacements), + native: prepareManifest(nativeReplacements), + preferred: prepareManifest(preferredReplacements) }; const defaultPresets = ['microutilities', 'native', 'preferred']; @@ -53,9 +68,24 @@ function hasMatchingEngine( /** * Callback used for the replacement listener */ +function hasMatchingModule(moduleNames: Set, source: string): boolean { + let candidate = source; + while (true) { + if (moduleNames.has(candidate)) { + return true; + } + + const separator = candidate.lastIndexOf('/'); + if (separator === -1) { + return false; + } + candidate = candidate.slice(0, separator); + } +} + function replacementListenerCallback( context: Rule.RuleContext, - manifests: ManifestModule[], + manifests: PreparedManifest[], allowedNames: Set, node: Rule.Node, source: string @@ -70,7 +100,11 @@ function replacementListenerCallback( let currentMapping: ModuleReplacementMapping | undefined; for (const manifest of manifests) { - for (const [moduleName, mapping] of Object.entries(manifest.mappings)) { + if (!hasMatchingModule(manifest.moduleNames, source)) { + continue; + } + + for (const [moduleName, mapping] of manifest.mappings) { if (moduleName === source || source.startsWith(`${moduleName}/`)) { currentMapping = mapping; for (const replacementId of mapping.replacements) { @@ -190,7 +224,7 @@ export const banDependencies: Rule.RuleModule = { }, create: (context) => { const options = context.options[0] as BanDependenciesOptions | undefined; - const manifests: ManifestModule[] = []; + const manifests: PreparedManifest[] = []; const presets = options?.presets ?? defaultPresets; const modules = options?.modules; const allowed = new Set(options?.allowed ?? []); @@ -223,7 +257,7 @@ export const banDependencies: Rule.RuleModule = { } } }; - manifests.push(customManifest); + manifests.push(prepareManifest(customManifest)); } if (packageJsonLikePath.test(context.filename)) { From d4539a144e4f099258ad3393b8618bbcecf0c9eb Mon Sep 17 00:00:00 2001 From: Tony Gentilcore <49661492+tony-scio@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:05:27 -0700 Subject: [PATCH 2/3] perf(ban-dependencies): look up mappings with a single Map walk Replace the Set pre-check plus ordered scan with a Map from module name to its mapping and manifest position. Walk the import's parent paths once and keep the earliest-listed match, preserving existing precedence. --- src/rules/ban-dependencies.ts | 50 ++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/rules/ban-dependencies.ts b/src/rules/ban-dependencies.ts index 46eedf7..60b774d 100644 --- a/src/rules/ban-dependencies.ts +++ b/src/rules/ban-dependencies.ts @@ -21,16 +21,19 @@ interface BanDependenciesOptions { } interface PreparedManifest { - mappings: Array<[string, ModuleReplacementMapping]>; - moduleNames: Set; + /** Maps each module name to its mapping and its position in the manifest */ + mappings: Map; replacements: ManifestModule['replacements']; } function prepareManifest(manifest: ManifestModule): PreparedManifest { - const mappings = Object.entries(manifest.mappings); return { - mappings, - moduleNames: new Set(mappings.map(([moduleName]) => moduleName)), + mappings: new Map( + Object.entries(manifest.mappings).map(([moduleName, mapping], index) => [ + moduleName, + {index, mapping} + ]) + ), replacements: manifest.replacements }; } @@ -66,23 +69,32 @@ function hasMatchingEngine( } /** - * Callback used for the replacement listener + * Finds the mapping for `source` or one of its parent paths. If several match, + * the one listed first in the manifest wins. */ -function hasMatchingModule(moduleNames: Set, source: string): boolean { +function findMapping( + manifest: PreparedManifest, + source: string +): ModuleReplacementMapping | undefined { + let match: {index: number; mapping: ModuleReplacementMapping} | undefined; let candidate = source; while (true) { - if (moduleNames.has(candidate)) { - return true; + const entry = manifest.mappings.get(candidate); + if (entry && (!match || entry.index < match.index)) { + match = entry; } const separator = candidate.lastIndexOf('/'); if (separator === -1) { - return false; + return match?.mapping; } candidate = candidate.slice(0, separator); } } +/** + * Callback used for the replacement listener + */ function replacementListenerCallback( context: Rule.RuleContext, manifests: PreparedManifest[], @@ -100,20 +112,16 @@ function replacementListenerCallback( let currentMapping: ModuleReplacementMapping | undefined; for (const manifest of manifests) { - if (!hasMatchingModule(manifest.moduleNames, source)) { + const mapping = findMapping(manifest, source); + if (!mapping) { continue; } - for (const [moduleName, mapping] of manifest.mappings) { - if (moduleName === source || source.startsWith(`${moduleName}/`)) { - currentMapping = mapping; - for (const replacementId of mapping.replacements) { - const replacement = manifest.replacements[replacementId]; - if (replacement) { - replacements.push(replacement); - } - } - break; + currentMapping = mapping; + for (const replacementId of mapping.replacements) { + const replacement = manifest.replacements[replacementId]; + if (replacement) { + replacements.push(replacement); } } } From c1b6e9325b38a849e21a7b82383c7c35a01285a5 Mon Sep 17 00:00:00 2001 From: Tony Gentilcore <49661492+tony-scio@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:50:23 -0700 Subject: [PATCH 3/3] perf(ban-dependencies): look up import paths instead of scanning mappings Replace the per-import scan of every manifest entry with direct lookups of the import path and its parent paths in the existing mappings object. --- src/rules/ban-dependencies.test.ts | 2 +- src/rules/ban-dependencies.ts | 62 ++++++++++-------------------- 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/rules/ban-dependencies.test.ts b/src/rules/ban-dependencies.test.ts index 1a75272..e5837f1 100644 --- a/src/rules/ban-dependencies.test.ts +++ b/src/rules/ban-dependencies.test.ts @@ -264,7 +264,7 @@ ruleTester.run('ban-dependencies', banDependencies, { column: 1, messageId: 'removalReplacement', data: { - name: 'oogabooga', + name: 'oogabooga/subpath', description: 'This module is disallowed and should be replaced with an alternative.' } diff --git a/src/rules/ban-dependencies.ts b/src/rules/ban-dependencies.ts index 60b774d..492a6cd 100644 --- a/src/rules/ban-dependencies.ts +++ b/src/rules/ban-dependencies.ts @@ -20,28 +20,10 @@ interface BanDependenciesOptions { allowed?: string[]; } -interface PreparedManifest { - /** Maps each module name to its mapping and its position in the manifest */ - mappings: Map; - replacements: ManifestModule['replacements']; -} - -function prepareManifest(manifest: ManifestModule): PreparedManifest { - return { - mappings: new Map( - Object.entries(manifest.mappings).map(([moduleName, mapping], index) => [ - moduleName, - {index, mapping} - ]) - ), - replacements: manifest.replacements - }; -} - -const availablePresets: Record = { - microutilities: prepareManifest(microUtilsReplacements), - native: prepareManifest(nativeReplacements), - preferred: prepareManifest(preferredReplacements) +const availablePresets: Record = { + microutilities: microUtilsReplacements, + native: nativeReplacements, + preferred: preferredReplacements }; const defaultPresets = ['microutilities', 'native', 'preferred']; @@ -69,27 +51,25 @@ function hasMatchingEngine( } /** - * Finds the mapping for `source` or one of its parent paths. If several match, - * the one listed first in the manifest wins. + * Finds the mapping for `source` or its closest parent path. For example, + * `foo/bar/baz` checks `foo/bar/baz`, then `foo/bar`, then `foo`. */ function findMapping( - manifest: PreparedManifest, + mappings: ManifestModule['mappings'], source: string ): ModuleReplacementMapping | undefined { - let match: {index: number; mapping: ModuleReplacementMapping} | undefined; - let candidate = source; - while (true) { - const entry = manifest.mappings.get(candidate); - if (entry && (!match || entry.index < match.index)) { - match = entry; - } - - const separator = candidate.lastIndexOf('/'); - if (separator === -1) { - return match?.mapping; + // `end` only decreases, so the loop always terminates. + for ( + let end = source.length; + end > 0; + end = source.lastIndexOf('/', end - 1) + ) { + const moduleName = source.slice(0, end); + if (Object.hasOwn(mappings, moduleName)) { + return mappings[moduleName]; } - candidate = candidate.slice(0, separator); } + return undefined; } /** @@ -97,7 +77,7 @@ function findMapping( */ function replacementListenerCallback( context: Rule.RuleContext, - manifests: PreparedManifest[], + manifests: ManifestModule[], allowedNames: Set, node: Rule.Node, source: string @@ -112,7 +92,7 @@ function replacementListenerCallback( let currentMapping: ModuleReplacementMapping | undefined; for (const manifest of manifests) { - const mapping = findMapping(manifest, source); + const mapping = findMapping(manifest.mappings, source); if (!mapping) { continue; } @@ -232,7 +212,7 @@ export const banDependencies: Rule.RuleModule = { }, create: (context) => { const options = context.options[0] as BanDependenciesOptions | undefined; - const manifests: PreparedManifest[] = []; + const manifests: ManifestModule[] = []; const presets = options?.presets ?? defaultPresets; const modules = options?.modules; const allowed = new Set(options?.allowed ?? []); @@ -265,7 +245,7 @@ export const banDependencies: Rule.RuleModule = { } } }; - manifests.push(prepareManifest(customManifest)); + manifests.push(customManifest); } if (packageJsonLikePath.test(context.filename)) {