From fbeee03fa945b7fb8a4e5e26454e964e29cc2933 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 19 Mar 2025 14:02:43 -0400 Subject: [PATCH 1/5] Auto-fixable missing invokable rule Working on an auto-fixed rule for inserting imports when you are missing an invokable (helper, modifier, component). --- lib/rules/template-missing-invokable.js | 71 +++++++ tests/lib/rules/template-missing-invokable.js | 182 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 lib/rules/template-missing-invokable.js create mode 100644 tests/lib/rules/template-missing-invokable.js diff --git a/lib/rules/template-missing-invokable.js b/lib/rules/template-missing-invokable.js new file mode 100644 index 0000000000..0baed2e6e8 --- /dev/null +++ b/lib/rules/template-missing-invokable.js @@ -0,0 +1,71 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow referencing let variables in \\', + category: 'Ember Octane', + recommendedGjs: false, + recommendedGts: false, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-missing-invokable.md', + }, + fixable: 'code', + schema: [], + messages: { + 'missing-invokable': + 'Not in scope. Did you forget to import this? Auto-fix may be configured.', + }, + }, + + create: (context) => { + const sourceCode = context.sourceCode; + + // TODO make real config + const config = { + eq: { name: 'eq', module: 'ember-truth-helpers' }, + on: { name: 'on', module: '@ember/modifier' }, + }; + + // takes a node with a `.path` property + function checkInvokable(node) { + if (node.path.type === 'GlimmerPathExpression' && node.path.tail.length === 0) { + if (!isBound(node.path.head, sourceCode.getScope(node.path))) { + const matched = config[node.path.head.name]; + if (matched) { + context.report({ + node: node.path, + messageId: 'missing-invokable', + fix(fixer) { + return fixer.insertTextBeforeRange( + [0, 0], + `import { ${matched.name} } from '${matched.module}';\n` + ); + }, + }); + } + } + } + } + + return { + GlimmerSubExpression(node) { + return checkInvokable(node); + }, + GlimmerElementModifierStatement(node) { + return checkInvokable(node); + }, + GlimmerMustacheStatement(node) { + return checkInvokable(node); + }, + }; + }, +}; + +function isBound(node, scope) { + const ref = scope.references.find((v) => v.identifier === node); + if (!ref) { + // TODO: can we make a test case for this? + return false; + } + return Boolean(ref.resolved); +} diff --git a/tests/lib/rules/template-missing-invokable.js b/tests/lib/rules/template-missing-invokable.js new file mode 100644 index 0000000000..42b3f8b34d --- /dev/null +++ b/tests/lib/rules/template-missing-invokable.js @@ -0,0 +1,182 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/template-missing-invokable'); +const RuleTester = require('eslint').RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); +ruleTester.run('template-missing-invokable', rule, { + valid: [ + // Subexpression Invocations + ` + import { eq } from 'somewhere'; + + `, + ` + function eq() {} + + `, + ` + function x(eq) { + + } + `, + + // Mustache Invocations + ` + import { eq } from 'somewhere'; + + `, + ` + import { eq } from 'somewhere'; + import MyComponent from 'somewhere'; + + `, + + // Modifier Invocations + ` + import { on } from 'somewhere'; + function doSomething() {} + + `, + ], + + invalid: [ + // Subexpression invocations + { + code: ` + + `, + output: `import { eq } from 'ember-truth-helpers'; + + + `, + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], + }, + + // Mustache Invocations + { + code: ` + + `, + output: `import { eq } from 'ember-truth-helpers'; + + + `, + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], + }, + { + code: ` + import MyComponent from 'somewhere'; + + `, + output: `import { eq } from 'ember-truth-helpers'; + + import MyComponent from 'somewhere'; + + `, + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], + }, + + // Modifier Inovcations + { + code: ` + function doSomething() {} + + `, + output: `import { on } from '@ember/modifier'; + + function doSomething() {} + + `, + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], + }, + // Multiple copies of a fixable invocation + { + code: ` + let other = + + + `, + output: `import { eq } from 'ember-truth-helpers'; + + let other = + + + `, + errors: [ + { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, + { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, + { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, + ], + }, + ], +}); From 2ef166a8fe3cbccd6017f34cce9c6b558a972b75 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 19 Mar 2025 16:26:11 -0400 Subject: [PATCH 2/5] filling in initial docs to get passing tests --- README.md | 2 ++ docs/rules/template-missing-invokable.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 docs/rules/template-missing-invokable.md diff --git a/README.md b/README.md index 299aaf45c0..0639f7e315 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,8 @@ rules in templates can be disabled with eslint directives with mustache or html | [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args | ✅ | | | | [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | | | [template-no-let-reference](docs/rules/template-no-let-reference.md) | disallow referencing let variables in \ | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | | +| [template-missing-invokable](docs/rules/template-missing-invokable.md) | auto-fix to import missing helpers, modifiers, or components from a configured list in \ | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | | + ### jQuery diff --git a/docs/rules/template-missing-invokable.md b/docs/rules/template-missing-invokable.md new file mode 100644 index 0000000000..daa0c02ee9 --- /dev/null +++ b/docs/rules/template-missing-invokable.md @@ -0,0 +1,20 @@ +# ember/template-missing-invokable + +Auto-fixes missing imports for helpers, modifiers, and components in your \ `, + options: [ + { + invokables: { + eq: ['eq', 'ember-truth-helpers'], + }, + }, + ], + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], }, @@ -101,6 +109,13 @@ ruleTester.run('template-missing-invokable', rule, { {{eq 1 1}} `, + options: [ + { + invokables: { + eq: ['eq', 'ember-truth-helpers'], + }, + }, + ], errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], }, { @@ -117,6 +132,13 @@ ruleTester.run('template-missing-invokable', rule, { `, + options: [ + { + invokables: { + eq: ['eq', 'ember-truth-helpers'], + }, + }, + ], errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], }, @@ -135,6 +157,13 @@ ruleTester.run('template-missing-invokable', rule, { `, + options: [ + { + invokables: { + on: ['on', '@ember/modifier'], + }, + }, + ], errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], }, // Multiple copies of a fixable invocation @@ -172,6 +201,13 @@ ruleTester.run('template-missing-invokable', rule, { {{/if}} `, + options: [ + { + invokables: { + eq: ['eq', 'ember-truth-helpers'], + }, + }, + ], errors: [ { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, From b6bbc3ee31830fb5e4b178dc054687996af1eb9a Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 19 Mar 2025 17:04:22 -0400 Subject: [PATCH 4/5] default export support --- lib/rules/template-missing-invokable.js | 16 +++++++--- tests/lib/rules/template-missing-invokable.js | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/rules/template-missing-invokable.js b/lib/rules/template-missing-invokable.js index 3136f55106..c13e00cff0 100644 --- a/lib/rules/template-missing-invokable.js +++ b/lib/rules/template-missing-invokable.js @@ -42,14 +42,12 @@ module.exports = { const matched = context.options[0]?.invokables?.[node.path.head.name]; if (matched) { const [name, module] = matched; + const importStatement = buildImportStatement(node.path.head.name, name, module); context.report({ node: node.path, messageId: 'missing-invokable', fix(fixer) { - return fixer.insertTextBeforeRange( - [0, 0], - `import { ${name} } from '${module}';\n` - ); + return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`); }, }); } @@ -78,3 +76,13 @@ function isBound(node, scope) { } return Boolean(ref.resolved); } + +function buildImportStatement(consumedName, exportedName, module) { + if (exportedName === 'default') { + return `import ${consumedName} from '${module}'`; + } else { + return consumedName === exportedName + ? `import { ${consumedName} } from '${module}'` + : `import { ${exportedName} as ${consumedName} } from '${module}'`; + } +} diff --git a/tests/lib/rules/template-missing-invokable.js b/tests/lib/rules/template-missing-invokable.js index 0dc8c98b57..0aa1706021 100644 --- a/tests/lib/rules/template-missing-invokable.js +++ b/tests/lib/rules/template-missing-invokable.js @@ -166,6 +166,7 @@ ruleTester.run('template-missing-invokable', rule, { ], errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], }, + // Multiple copies of a fixable invocation { code: ` @@ -214,5 +215,33 @@ ruleTester.run('template-missing-invokable', rule, { { type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }, ], }, + + // Auto-fix with a default export + { + code: ` + + `, + output: `import eq from 'ember-truth-helpers/helpers/equal'; + + + `, + options: [ + { + invokables: { + eq: ['default', 'ember-truth-helpers/helpers/equal'], + }, + }, + ], + + errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }], + }, ], }); From 870ee2fa24ae826d431fca8e6109dbc389f368f6 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 19 Mar 2025 17:26:20 -0400 Subject: [PATCH 5/5] docs lint --- README.md | 3 +-- docs/rules/template-missing-invokable.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0639f7e315..3335ec33d5 100644 --- a/README.md +++ b/README.md @@ -261,9 +261,8 @@ rules in templates can be disabled with eslint directives with mustache or html | [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components | ✅ | | | | [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args | ✅ | | | | [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | | +| [template-missing-invokable](docs/rules/template-missing-invokable.md) | disallow missing helpers, modifiers, or components in \ with auto-fix to import them | | 🔧 | | | [template-no-let-reference](docs/rules/template-no-let-reference.md) | disallow referencing let variables in \ | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | | -| [template-missing-invokable](docs/rules/template-missing-invokable.md) | auto-fix to import missing helpers, modifiers, or components from a configured list in \ | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | | - ### jQuery diff --git a/docs/rules/template-missing-invokable.md b/docs/rules/template-missing-invokable.md index daa0c02ee9..cf0fd3abf2 100644 --- a/docs/rules/template-missing-invokable.md +++ b/docs/rules/template-missing-invokable.md @@ -1,5 +1,9 @@ # ember/template-missing-invokable +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + Auto-fixes missing imports for helpers, modifiers, and components in your \ ``` + +## Examples + +## Config + +- invokables