|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const path = require('node:path'); |
| 4 | +const fs = require('node:fs'); |
| 5 | + |
| 6 | +// Packages that ship with Ember/Glimmer are always available to auto-fix. |
| 7 | +function isBuiltinPackage(moduleName) { |
| 8 | + return moduleName.startsWith('@ember/') || moduleName.startsWith('@glimmer/'); |
| 9 | +} |
| 10 | + |
| 11 | +// Returns the root package name from a module specifier, e.g. |
| 12 | +// 'ember-truth-helpers' -> 'ember-truth-helpers' |
| 13 | +// 'ember-truth-helpers/helpers' -> 'ember-truth-helpers' |
| 14 | +// '@scope/pkg/deep' -> '@scope/pkg' |
| 15 | +function rootPackageName(moduleName) { |
| 16 | + if (moduleName.startsWith('@')) { |
| 17 | + const parts = moduleName.split('/'); |
| 18 | + return parts.slice(0, 2).join('/'); |
| 19 | + } |
| 20 | + return moduleName.split('/')[0]; |
| 21 | +} |
| 22 | + |
| 23 | +// Walk up the directory tree from startDir to find the nearest package.json. |
| 24 | +function findNearestPackageJson(startDir) { |
| 25 | + let dir = startDir; |
| 26 | + let parent = path.dirname(dir); |
| 27 | + while (dir !== parent) { |
| 28 | + const candidate = path.join(dir, 'package.json'); |
| 29 | + if (fs.existsSync(candidate)) { |
| 30 | + return candidate; |
| 31 | + } |
| 32 | + dir = parent; |
| 33 | + parent = path.dirname(dir); |
| 34 | + } |
| 35 | + return null; |
| 36 | +} |
| 37 | + |
| 38 | +function isPackageInProjectDeps(moduleName, fileDir) { |
| 39 | + try { |
| 40 | + const pkgPath = findNearestPackageJson(fileDir); |
| 41 | + if (!pkgPath) { |
| 42 | + return false; |
| 43 | + } |
| 44 | + const packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); |
| 45 | + const pkg = rootPackageName(moduleName); |
| 46 | + return Boolean( |
| 47 | + (packageJson.dependencies && pkg in packageJson.dependencies) || |
| 48 | + (packageJson.devDependencies && pkg in packageJson.devDependencies) || |
| 49 | + (packageJson.peerDependencies && pkg in packageJson.peerDependencies) |
| 50 | + ); |
| 51 | + } catch { |
| 52 | + return false; |
| 53 | + } |
| 54 | +} |
| 55 | + |
1 | 56 | /** @type {import('eslint').Rule.RuleModule} */ |
2 | 57 | module.exports = { |
3 | 58 | meta: { |
@@ -41,14 +96,23 @@ module.exports = { |
41 | 96 | if (!isBound(node.path.head, sourceCode.getScope(node.path))) { |
42 | 97 | const matched = context.options[0]?.invokables?.[node.path.head.name]; |
43 | 98 | if (matched) { |
44 | | - const [name, module] = matched; |
45 | | - const importStatement = buildImportStatement(node.path.head.name, name, module); |
| 99 | + const [name, moduleName] = matched; |
| 100 | + const fileDir = path.dirname( |
| 101 | + path.resolve(context.getPhysicalFilename?.() ?? context.getFilename()) |
| 102 | + ); |
| 103 | + const canAutoFix = |
| 104 | + isBuiltinPackage(moduleName) || |
| 105 | + isPackageInProjectDeps(moduleName, fileDir); |
| 106 | + |
| 107 | + const importStatement = buildImportStatement(node.path.head.name, name, moduleName); |
46 | 108 | context.report({ |
47 | 109 | node: node.path, |
48 | 110 | messageId: 'missing-invokable', |
49 | | - fix(fixer) { |
50 | | - return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`); |
51 | | - }, |
| 111 | + fix: canAutoFix |
| 112 | + ? function (fixer) { |
| 113 | + return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`); |
| 114 | + } |
| 115 | + : null, |
52 | 116 | }); |
53 | 117 | } |
54 | 118 | } |
|
0 commit comments