From eac4005e3778213901fb90b733280e2fa918a44f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:23:03 -0400 Subject: [PATCH 1/3] Extract rule: template-no-restricted-invocations --- README.md | 1 + .../template-no-restricted-invocations.md | 77 +++ .../template-no-restricted-invocations.js | 346 +++++++++++ .../template-no-restricted-invocations.js | 570 ++++++++++++++++++ 4 files changed, 994 insertions(+) create mode 100644 docs/rules/template-no-restricted-invocations.md create mode 100644 lib/rules/template-no-restricted-invocations.js create mode 100644 tests/lib/rules/template-no-restricted-invocations.js diff --git a/README.md b/README.md index c6191f2562..d9944fa5af 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | | | [template-no-positional-data-test-selectors](docs/rules/template-no-positional-data-test-selectors.md) | disallow positional data-test-* params in curly invocations | | | | | [template-no-potential-path-strings](docs/rules/template-no-potential-path-strings.md) | disallow potential path strings in attribute values | | | | +| [template-no-restricted-invocations](docs/rules/template-no-restricted-invocations.md) | disallow certain components, helpers or modifiers from being used | | | | | [template-no-splattributes-with-class](docs/rules/template-no-splattributes-with-class.md) | disallow splattributes with class attribute | | | | | [template-no-this-in-template-only-components](docs/rules/template-no-this-in-template-only-components.md) | disallow this in template-only components (gjs/gts) | | 🔧 | | | [template-no-trailing-spaces](docs/rules/template-no-trailing-spaces.md) | disallow trailing whitespace at the end of lines in templates | | 🔧 | | diff --git a/docs/rules/template-no-restricted-invocations.md b/docs/rules/template-no-restricted-invocations.md new file mode 100644 index 0000000000..af32a5ccf0 --- /dev/null +++ b/docs/rules/template-no-restricted-invocations.md @@ -0,0 +1,77 @@ +# ember/template-no-restricted-invocations + + + +Disallow certain components, helpers or modifiers from being used. + +Use cases include: + +- You bring in some addon with helpers or components, but your team deems one or many not suitable and wants to guard against their usage +- You want to discourage use of a deprecated component + +## Examples + +Given a config of: + +```json +{ "template-no-restricted-invocations": ["foo-bar"] } +``` + +This rule **forbids** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +## Configuration + +One of these: + +- `string[]` - helpers or components to disallow (using kebab-case names like `nested-scope/component-name`) +- `object[]` - with the following keys: + - `names` - `string[]` - helpers or components to disallow + - `message` - `string` - custom error message to report for violations + +```js +// .eslintrc.js +module.exports = { + rules: { + 'ember/template-no-restricted-invocations': [ + 'error', + [ + 'foo-bar', + { + names: ['deprecated-component'], + message: 'Use new-component instead', + }, + ], + ], + }, +}; +``` + +## Related Rules + +- [ember/no-restricted-service-injections](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/no-restricted-service-injections.md) + +## References + +- [emberjs.com - Deprecations](https://guides.emberjs.com/release/deprecations/) diff --git a/lib/rules/template-no-restricted-invocations.js b/lib/rules/template-no-restricted-invocations.js new file mode 100644 index 0000000000..eadcb6f450 --- /dev/null +++ b/lib/rules/template-no-restricted-invocations.js @@ -0,0 +1,346 @@ +/* eslint-disable unicorn/consistent-function-scoping, unicorn/prefer-switch, curly */ +const COMPONENT_HELPER_NAME = 'component'; + +function dasherize(str) { + return str + .split('::') + .map((segment) => + segment + .replaceAll(/([A-Z])/g, '-$1') + .toLowerCase() + .replace(/^-/, '') + ) + .join('/'); +} + +function parseConfig(config) { + // If config is not provided, disable the rule + if (config === false || config === undefined) { + return false; + } + + // If it's true, use empty array + if (config === true) { + return []; + } + + // If it's an array, validate it + if (Array.isArray(config)) { + return config; + } + + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow certain components, helpers or modifiers from being used', + category: 'Best Practices', + recommended: false, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-restricted-invocations.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + oneOf: [ + { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + properties: { + names: { + type: 'array', + items: { + type: 'string', + }, + }, + message: { + type: 'string', + }, + }, + required: ['names', 'message'], + additionalProperties: false, + }, + ], + }, + }, + ], + }, + ], + messages: {}, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-restricted-invocations.js', + docs: 'docs/rule/no-restricted-invocations.md', + tests: 'test/unit/rules/no-restricted-invocations-test.js', + }, + }, + + create(context) { + const config = parseConfig(context.options[0]); + + if (config === false) { + return {}; + } + + // Track block params in a scope stack so yielded names are not flagged. + const blockParamScopes = []; + + function pushBlockParams(params) { + blockParamScopes.push(new Set(params || [])); + } + + function popBlockParams() { + blockParamScopes.pop(); + } + + function isBlockParam(name) { + for (const scope of blockParamScopes) { + if (scope.has(name)) { + return true; + } + } + return false; + } + + function isRestricted(name) { + for (const item of config) { + if (typeof item === 'string') { + if (item === name) { + return { restricted: true, message: null }; + } + } else if (item.names && item.names.includes(name)) { + return { restricted: true, message: item.message }; + } + } + return { restricted: false }; + } + + function getComponentOrHelperName(node) { + if (node.type === 'GlimmerElementNode') { + // Convert angle-bracket names to kebab-case + return dasherize(node.tag); + } + + if (node.type === 'GlimmerMustacheStatement' || node.type === 'GlimmerBlockStatement') { + // Check if it's the component helper + if (node.path.original === COMPONENT_HELPER_NAME && node.params && node.params[0]) { + // component helper with first param + if (node.params[0].type === 'GlimmerStringLiteral') { + return node.params[0].value; + } + } + return node.path.original; + } + + if (node.type === 'GlimmerModifierStatement') { + return node.path.original; + } + + if (node.type === 'GlimmerSubExpression') { + if (node.path.original === COMPONENT_HELPER_NAME && node.params && node.params[0]) { + if (node.params[0].type === 'GlimmerStringLiteral') { + return node.params[0].value; + } + } + return node.path.original; + } + + return null; + } + + function getNodeName(node) { + switch (node.type) { + case 'GlimmerElementNode': { + return `<${node.tag} />`; + } + case 'GlimmerMustacheStatement': { + if ( + node.path.original === COMPONENT_HELPER_NAME && + node.params?.[0]?.type === 'GlimmerStringLiteral' + ) { + return `{{component "${node.params[0].value}"}}`; + } + return `{{${node.path.original}}}`; + } + case 'GlimmerBlockStatement': { + if ( + node.path.original === COMPONENT_HELPER_NAME && + node.params?.[0]?.type === 'GlimmerStringLiteral' + ) { + return `{{#component "${node.params[0].value}"}}`; + } + return `{{#${node.path.original}}}`; + } + case 'GlimmerModifierStatement': { + return `{{${node.path.original}}}`; + } + case 'GlimmerSubExpression': { + if ( + node.path.original === COMPONENT_HELPER_NAME && + node.params?.[0]?.type === 'GlimmerStringLiteral' + ) { + return `(component "${node.params[0].value}")`; + } + return `(${node.path.original})`; + } + // No default + } + return ''; + } + + return { + GlimmerElementNode(node) { + // For element nodes, check the raw tag against block params before dasherizing. + if (node.tag && isBlockParam(node.tag)) { + // Track block params from element nodes (e.g. ). + if (node.blockParams && node.blockParams.length > 0) { + pushBlockParams(node.blockParams); + } + return; + } + + const name = getComponentOrHelperName(node); + if (name && !isBlockParam(name)) { + const result = isRestricted(name); + if (result.restricted) { + context.report({ + node, + message: + result.message || + `Cannot use disallowed helper, component or modifier '${getNodeName(node)}'`, + }); + } + } + + // Track block params from element nodes (e.g. ). + if (node.blockParams && node.blockParams.length > 0) { + pushBlockParams(node.blockParams); + } + + // Check modifiers on the element + if (node.modifiers) { + for (const modifier of node.modifiers) { + const modName = + modifier.path && + modifier.path.type === 'GlimmerPathExpression' && + modifier.path.original; + if (!modName) continue; + if (isBlockParam(modName)) continue; + + const modResult = isRestricted(modName); + if (modResult.restricted) { + context.report({ + node: modifier, + message: + modResult.message || + `Cannot use disallowed helper, component or modifier '{{${modName}}}'`, + }); + } + } + } + }, + + 'GlimmerElementNode:exit'(node) { + if (node.blockParams && node.blockParams.length > 0) { + popBlockParams(); + } + }, + + GlimmerMustacheStatement(node) { + const name = getComponentOrHelperName(node); + if (!name) { + return; + } + if (isBlockParam(name)) { + return; + } + + const result = isRestricted(name); + if (result.restricted) { + context.report({ + node, + message: + result.message || + `Cannot use disallowed helper, component or modifier '${getNodeName(node)}'`, + }); + } + }, + + GlimmerBlockStatement(node) { + const name = getComponentOrHelperName(node); + if (name && !isBlockParam(name)) { + const result = isRestricted(name); + if (result.restricted) { + context.report({ + node, + message: + result.message || + `Cannot use disallowed helper, component or modifier '${getNodeName(node)}'`, + }); + } + } + + // Track block params (e.g. {{#each items as |item|}}). + if (node.program && node.program.blockParams) { + pushBlockParams(node.program.blockParams); + } + }, + + 'GlimmerBlockStatement:exit'(node) { + if (node.program && node.program.blockParams) { + popBlockParams(); + } + }, + + GlimmerModifierStatement(node) { + const name = getComponentOrHelperName(node); + if (!name) { + return; + } + if (isBlockParam(name)) { + return; + } + + const result = isRestricted(name); + if (result.restricted) { + context.report({ + node, + message: + result.message || `Cannot use disallowed helper, component or modifier '{{${name}}}'`, + }); + } + }, + + GlimmerSubExpression(node) { + const name = getComponentOrHelperName(node); + if (!name) { + return; + } + if (isBlockParam(name)) { + return; + } + + const result = isRestricted(name); + if (result.restricted) { + context.report({ + node, + message: + result.message || + `Cannot use disallowed helper, component or modifier '${getNodeName(node)}'`, + }); + } + }, + }; + }, +}; +/* eslint-enable unicorn/consistent-function-scoping, unicorn/prefer-switch, curly */ diff --git a/tests/lib/rules/template-no-restricted-invocations.js b/tests/lib/rules/template-no-restricted-invocations.js new file mode 100644 index 0000000000..c700f8d3ae --- /dev/null +++ b/tests/lib/rules/template-no-restricted-invocations.js @@ -0,0 +1,570 @@ +const rule = require('../../../lib/rules/template-no-restricted-invocations'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-restricted-invocations', rule, { + valid: [ + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + }, + + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + options: [['foo', 'bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{foo}}'", + }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{#foo}}'", + }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier ''", + }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{bar}}'", + }, + ], + }, + { + code: '', + output: null, + options: [ + [ + 'foo', + { + names: ['deprecated-component'], + message: 'Use new-component instead', + }, + ], + ], + errors: [ + { + message: 'Use new-component instead', + }, + ], + }, + + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '{{foo}}'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '{{foo}}'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '{{foo}}'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '{{#foo}}'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '{{#foo}}'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'' }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '(foo)'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '(foo)'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '(foo)'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [{ message: "Cannot use disallowed helper, component or modifier '(foo)'" }], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{nested-scope/foo-bar}}'", + }, + ], + }, + { + code: '', + output: null, + options: [['foo', 'nested-scope/foo-bar']], + errors: [ + { + message: "Cannot use disallowed helper, component or modifier ''", + }, + ], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-no-restricted-invocations', rule, { + valid: [ + '{{baz}}', + '{{baz foo=bar}}', + '{{baz foo=(baz)}}', + '{{#baz}}{{/baz}}', + '{{#baz foo=bar}}{{/baz}}', + '{{#baz foo=(baz)}}{{/baz}}', + '{{component}}', + '{{component "baz"}}', + '{{component "baz" foo=bar}}', + '{{component "baz" foo=(baz)}}', + '{{#component "baz"}}{{/component}}', + '{{#component "baz" foo=bar}}{{/component}}', + '{{#component "baz" foo=(baz)}}{{/component}}', + '{{yield (component "baz")}}', + '{{yield (component "baz" foo=bar)}}', + '{{yield (component "baz" foo=(baz))}}', + '{{yield (baz (baz (baz) bar))}}', + '{{yield (baz (baz (baz) (baz)))}}', + '{{yield (baz (baz (baz) foo=(baz)))}}', + '{{#baz as |foo|}}{{foo}}{{/baz}}', + '{{#with (component "blah") as |Foo|}} {{/with}}', + '{{other/foo-bar}}', + '{{nested-scope/other}}', + '', + '', + '', + ].map((code) => ({ + code: typeof code === 'string' ? code : code.code, + options: [ + [ + 'foo', + 'bar', + 'nested-scope/foo-bar', + { + names: ['deprecated-component'], + message: 'This component is deprecated; use component ABC instead.', + }, + ], + ], + })), + invalid: [ + // Modifier on element. + { + code: '
', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{foo}}'", + }, + ], + }, + // Mustache invocations. + { + code: '{{foo}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{foo}}'", + }, + ], + }, + { + code: '{{foo foo=bar}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{foo}}'", + }, + ], + }, + { + code: '{{foo foo=(baz)}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{foo}}'", + }, + ], + }, + // Angle bracket invocation. + { + code: '', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier ''", + }, + ], + }, + // Block invocations. + { + code: '{{#foo}}{{/foo}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{#foo}}'", + }, + ], + }, + { + code: '{{#foo foo=bar}}{{/foo}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{#foo}}'", + }, + ], + }, + { + code: '{{#foo foo=(baz)}}{{/foo}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{#foo}}'", + }, + ], + }, + // Component helper invocations. + { + code: '{{component "foo"}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'', + }, + ], + }, + { + code: '{{component "foo" foo=bar}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'', + }, + ], + }, + { + code: '{{component "foo" foo=(baz)}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{component "foo"}}\'', + }, + ], + }, + { + code: '{{#component "foo"}}{{/component}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'', + }, + ], + }, + { + code: '{{#component "foo" foo=bar}}{{/component}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'', + }, + ], + }, + { + code: '{{#component "foo" foo=(baz)}}{{/component}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'{{#component "foo"}}\'', + }, + ], + }, + // Subexpression with component helper. + { + code: '{{yield (component "foo")}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'', + }, + ], + }, + { + code: '{{yield (component "foo" foo=bar)}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'', + }, + ], + }, + { + code: '{{yield (component "foo" foo=(baz))}}', + output: null, + errors: [ + { + message: 'Cannot use disallowed helper, component or modifier \'(component "foo")\'', + }, + ], + }, + // Nested subexpressions. + { + code: '{{yield (baz (foo (baz) bar))}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '(foo)'", + }, + ], + }, + { + code: '{{yield (baz (baz (baz) (foo)))}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '(foo)'", + }, + ], + }, + { + code: '{{yield (baz (baz (baz) foo=(foo)))}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '(foo)'", + }, + ], + }, + { + code: '{{#baz as |bar|}}{{bar foo=(foo)}}{{/baz}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '(foo)'", + }, + ], + }, + // Nested scope (slash path). + { + code: '{{nested-scope/foo-bar}}', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier '{{nested-scope/foo-bar}}'", + }, + ], + }, + // Nested scope (angle bracket with ::). + { + code: '', + output: null, + errors: [ + { + message: "Cannot use disallowed helper, component or modifier ''", + }, + ], + }, + // Custom message from config object. + { + code: '{{deprecated-component}}', + output: null, + errors: [ + { + message: 'This component is deprecated; use component ABC instead.', + }, + ], + }, + ].map((test) => ({ + ...test, + options: [ + [ + 'foo', + 'bar', + 'nested-scope/foo-bar', + { + names: ['deprecated-component'], + message: 'This component is deprecated; use component ABC instead.', + }, + ], + ], + })), +}); From 5234d161fd9ee25b8c842921c40c32df2aa0d33f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:34:25 -0400 Subject: [PATCH 2/3] Fix: exempt JS-scope locals from restriction checks in gjs/gts When in gjs/gts mode, names that resolve to JS-scope variables (imports, const, let, function params) are now treated the same as block params and exempted from restriction checks. Previously, block params were exempt but JS-scope bindings were not, which was inconsistent -- both are local bindings that the developer explicitly controls. Uses sourceCode.getScope(node) to check whether a reference resolves to an actual variable definition (ref.resolved != null), ensuring that ambient/global names are still flagged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../template-no-restricted-invocations.js | 53 ++++++++++++++++++- .../template-no-restricted-invocations.js | 45 ++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/lib/rules/template-no-restricted-invocations.js b/lib/rules/template-no-restricted-invocations.js index eadcb6f450..b513ebc813 100644 --- a/lib/rules/template-no-restricted-invocations.js +++ b/lib/rules/template-no-restricted-invocations.js @@ -92,6 +92,8 @@ module.exports = { return {}; } + const sourceCode = context.sourceCode; + // Track block params in a scope stack so yielded names are not flagged. const blockParamScopes = []; @@ -112,6 +114,37 @@ module.exports = { return false; } + /** + * In gjs/gts, check whether a name resolves to a JS-scope variable + * (import, const, let, function param, etc.). If it does, it's a local + * binding and should be exempt from restriction checks — same as block params. + */ + function isJsScopeVariable(node) { + if (!sourceCode) return false; + + try { + if (node.type === 'GlimmerElementNode') { + // Element nodes use parts[0] for scope lookup, and need parent scope + if (!node.parts || !node.parts[0]) return false; + const scope = sourceCode.getScope(node.parent); + const ref = scope.references.find((r) => r.identifier === node.parts[0]); + // Only exempt if the reference actually resolves to a JS variable definition + return ref != null && ref.resolved != null; + } + + // For mustache/block/sub/modifier statements, check the path's head + if (node.path && node.path.head) { + const scope = sourceCode.getScope(node); + const ref = scope.references.find((r) => r.identifier === node.path.head); + return ref != null && ref.resolved != null; + } + } catch { + // sourceCode.getScope may not be available in .hbs-only mode; ignore. + } + + return false; + } + function isRestricted(name) { for (const item of config) { if (typeof item === 'string') { @@ -209,6 +242,14 @@ module.exports = { return; } + // In gjs/gts, skip if the tag resolves to a JS-scope variable (import, const, etc.) + if (isJsScopeVariable(node)) { + if (node.blockParams && node.blockParams.length > 0) { + pushBlockParams(node.blockParams); + } + return; + } + const name = getComponentOrHelperName(node); if (name && !isBlockParam(name)) { const result = isRestricted(name); @@ -236,6 +277,7 @@ module.exports = { modifier.path.original; if (!modName) continue; if (isBlockParam(modName)) continue; + if (isJsScopeVariable(modifier)) continue; const modResult = isRestricted(modName); if (modResult.restricted) { @@ -264,6 +306,9 @@ module.exports = { if (isBlockParam(name)) { return; } + if (isJsScopeVariable(node)) { + return; + } const result = isRestricted(name); if (result.restricted) { @@ -278,7 +323,7 @@ module.exports = { GlimmerBlockStatement(node) { const name = getComponentOrHelperName(node); - if (name && !isBlockParam(name)) { + if (name && !isBlockParam(name) && !isJsScopeVariable(node)) { const result = isRestricted(name); if (result.restricted) { context.report({ @@ -310,6 +355,9 @@ module.exports = { if (isBlockParam(name)) { return; } + if (isJsScopeVariable(node)) { + return; + } const result = isRestricted(name); if (result.restricted) { @@ -329,6 +377,9 @@ module.exports = { if (isBlockParam(name)) { return; } + if (isJsScopeVariable(node)) { + return; + } const result = isRestricted(name); if (result.restricted) { diff --git a/tests/lib/rules/template-no-restricted-invocations.js b/tests/lib/rules/template-no-restricted-invocations.js index c700f8d3ae..cd5eaa0573 100644 --- a/tests/lib/rules/template-no-restricted-invocations.js +++ b/tests/lib/rules/template-no-restricted-invocations.js @@ -66,6 +66,51 @@ ruleTester.run('template-no-restricted-invocations', rule, { '', '', '', + + // JS-scope variables (imports, const, let) should be exempt — same as block params. + { + code: ` + import foo from './foo'; + + `, + options: [['foo', 'bar']], + }, + { + code: ` + import Foo from './foo'; + + `, + options: [['foo', 'bar']], + }, + { + code: ` + const foo = () => {}; + + `, + options: [['foo', 'bar']], + }, + { + code: ` + import foo from './foo'; + + `, + options: [['foo', 'bar']], + }, + { + code: ` + import bar from './bar'; + + `, + options: [['foo', 'bar']], + }, + { + code: ` + import foo from './foo'; + import bar from './bar'; + + `, + options: [['foo', 'bar']], + }, ], invalid: [ { From 114c6ffe8d2e5afa5116991355feb8a4694bcd7c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:18:12 -0400 Subject: [PATCH 3/3] Sync with ember-template-lint --- .../template-no-restricted-invocations.md | 54 ++++--------- .../template-no-restricted-invocations.js | 75 ++++++++++--------- 2 files changed, 52 insertions(+), 77 deletions(-) diff --git a/docs/rules/template-no-restricted-invocations.md b/docs/rules/template-no-restricted-invocations.md index af32a5ccf0..c7c1a05b2f 100644 --- a/docs/rules/template-no-restricted-invocations.md +++ b/docs/rules/template-no-restricted-invocations.md @@ -6,7 +6,7 @@ Disallow certain components, helpers or modifiers from being used. Use cases include: -- You bring in some addon with helpers or components, but your team deems one or many not suitable and wants to guard against their usage +- You bring in some addon like ember-composable-helpers, but your team deems one or many of the helpers not suitable and wants to guard against their usage - You want to discourage use of a deprecated component ## Examples @@ -14,59 +14,31 @@ Use cases include: Given a config of: ```json -{ "template-no-restricted-invocations": ["foo-bar"] } +["foo-bar"] ``` This rule **forbids** the following: -```gjs - +```hbs +{{foo-bar}} ``` -```gjs - +```hbs +{{#foo-bar}}{{/foo-bar}} ``` -```gjs - -``` - -This rule **allows** the following: - -```gjs - -``` - -```gjs - +```hbs + ``` ## Configuration One of these: -- `string[]` - helpers or components to disallow (using kebab-case names like `nested-scope/component-name`) -- `object[]` - with the following keys: - - `names` - `string[]` - helpers or components to disallow - - `message` - `string` - custom error message to report for violations - -```js -// .eslintrc.js -module.exports = { - rules: { - 'ember/template-no-restricted-invocations': [ - 'error', - [ - 'foo-bar', - { - names: ['deprecated-component'], - message: 'Use new-component instead', - }, - ], - ], - }, -}; -``` +- string[] - helpers or components to disallow (using kebab-case names like `nested-scope/component-name`) +- object[] - with the following keys: + - `names` - string[] - helpers or components to disallow (using kebab-case names like `nested-scope/component-name`) + - `message` - string - custom error message to report for violations (typically a deprecation notice / explanation of why not to use it and a recommended replacement) ## Related Rules @@ -74,4 +46,4 @@ module.exports = { ## References -- [emberjs.com - Deprecations](https://guides.emberjs.com/release/deprecations/) +- [ember-cli-deprecation-workflow](https://github.com/mixonic/ember-cli-deprecation-workflow) diff --git a/lib/rules/template-no-restricted-invocations.js b/lib/rules/template-no-restricted-invocations.js index b513ebc813..1ddc1c00c3 100644 --- a/lib/rules/template-no-restricted-invocations.js +++ b/lib/rules/template-no-restricted-invocations.js @@ -129,14 +129,18 @@ module.exports = { const scope = sourceCode.getScope(node.parent); const ref = scope.references.find((r) => r.identifier === node.parts[0]); // Only exempt if the reference actually resolves to a JS variable definition - return ref != null && ref.resolved != null; + return ( + ref !== null && ref !== undefined && ref.resolved !== null && ref.resolved !== undefined + ); } // For mustache/block/sub/modifier statements, check the path's head if (node.path && node.path.head) { const scope = sourceCode.getScope(node); const ref = scope.references.find((r) => r.identifier === node.path.head); - return ref != null && ref.resolved != null; + return ( + ref !== null && ref !== undefined && ref.resolved !== null && ref.resolved !== undefined + ); } } catch { // sourceCode.getScope may not be available in .hbs-only mode; ignore. @@ -231,22 +235,46 @@ module.exports = { return ''; } + function checkElementModifiers(node) { + if (!node.modifiers) { + return; + } + for (const modifier of node.modifiers) { + const modName = + modifier.path && modifier.path.type === 'GlimmerPathExpression' && modifier.path.original; + if (!modName) continue; + if (isBlockParam(modName)) continue; + if (isJsScopeVariable(modifier)) continue; + + const modResult = isRestricted(modName); + if (modResult.restricted) { + context.report({ + node: modifier, + message: + modResult.message || + `Cannot use disallowed helper, component or modifier '{{${modName}}}'`, + }); + } + } + } + + function trackBlockParams(node) { + if (node.blockParams && node.blockParams.length > 0) { + pushBlockParams(node.blockParams); + } + } + return { GlimmerElementNode(node) { // For element nodes, check the raw tag against block params before dasherizing. if (node.tag && isBlockParam(node.tag)) { - // Track block params from element nodes (e.g. ). - if (node.blockParams && node.blockParams.length > 0) { - pushBlockParams(node.blockParams); - } + trackBlockParams(node); return; } // In gjs/gts, skip if the tag resolves to a JS-scope variable (import, const, etc.) if (isJsScopeVariable(node)) { - if (node.blockParams && node.blockParams.length > 0) { - pushBlockParams(node.blockParams); - } + trackBlockParams(node); return; } @@ -263,33 +291,8 @@ module.exports = { } } - // Track block params from element nodes (e.g. ). - if (node.blockParams && node.blockParams.length > 0) { - pushBlockParams(node.blockParams); - } - - // Check modifiers on the element - if (node.modifiers) { - for (const modifier of node.modifiers) { - const modName = - modifier.path && - modifier.path.type === 'GlimmerPathExpression' && - modifier.path.original; - if (!modName) continue; - if (isBlockParam(modName)) continue; - if (isJsScopeVariable(modifier)) continue; - - const modResult = isRestricted(modName); - if (modResult.restricted) { - context.report({ - node: modifier, - message: - modResult.message || - `Cannot use disallowed helper, component or modifier '{{${modName}}}'`, - }); - } - } - } + trackBlockParams(node); + checkElementModifiers(node); }, 'GlimmerElementNode:exit'(node) {