diff --git a/src/rules/ban-dependencies.test.ts b/src/rules/ban-dependencies.test.ts index 3c86afb..e5837f1 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/subpath', + 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..492a6cd 100644 --- a/src/rules/ban-dependencies.ts +++ b/src/rules/ban-dependencies.ts @@ -50,6 +50,28 @@ function hasMatchingEngine( return closestPackageSatisfiesNodeVersion(context, engineRange); } +/** + * 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( + mappings: ManifestModule['mappings'], + source: string +): ModuleReplacementMapping | undefined { + // `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]; + } + } + return undefined; +} + /** * Callback used for the replacement listener */ @@ -70,16 +92,16 @@ function replacementListenerCallback( let currentMapping: ModuleReplacementMapping | undefined; for (const manifest of manifests) { - for (const [moduleName, mapping] of Object.entries(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; + const mapping = findMapping(manifest.mappings, source); + if (!mapping) { + continue; + } + + currentMapping = mapping; + for (const replacementId of mapping.replacements) { + const replacement = manifest.replacements[replacementId]; + if (replacement) { + replacements.push(replacement); } } }