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..c7c1a05b2f --- /dev/null +++ b/docs/rules/template-no-restricted-invocations.md @@ -0,0 +1,49 @@ +# ember/template-no-restricted-invocations + + + +Disallow certain components, helpers or modifiers from being used. + +Use cases include: + +- 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 + +Given a config of: + +```json +["foo-bar"] +``` + +This rule **forbids** the following: + +```hbs +{{foo-bar}} +``` + +```hbs +{{#foo-bar}}{{/foo-bar}} +``` + +```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 (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 + +- [ember/no-restricted-service-injections](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/no-restricted-service-injections.md) + +## References + +- [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 new file mode 100644 index 0000000000..1ddc1c00c3 --- /dev/null +++ b/lib/rules/template-no-restricted-invocations.js @@ -0,0 +1,400 @@ +/* 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 {}; + } + + const sourceCode = context.sourceCode; + + // 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; + } + + /** + * 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 !== 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 !== undefined && ref.resolved !== null && ref.resolved !== undefined + ); + } + } 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') { + 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 ''; + } + + 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)) { + trackBlockParams(node); + return; + } + + // In gjs/gts, skip if the tag resolves to a JS-scope variable (import, const, etc.) + if (isJsScopeVariable(node)) { + trackBlockParams(node); + 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)}'`, + }); + } + } + + trackBlockParams(node); + checkElementModifiers(node); + }, + + 'GlimmerElementNode:exit'(node) { + if (node.blockParams && node.blockParams.length > 0) { + popBlockParams(); + } + }, + + GlimmerMustacheStatement(node) { + const name = getComponentOrHelperName(node); + if (!name) { + return; + } + if (isBlockParam(name)) { + return; + } + if (isJsScopeVariable(node)) { + 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) && !isJsScopeVariable(node)) { + 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; + } + if (isJsScopeVariable(node)) { + 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; + } + if (isJsScopeVariable(node)) { + 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..cd5eaa0573 --- /dev/null +++ b/tests/lib/rules/template-no-restricted-invocations.js @@ -0,0 +1,615 @@ +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']], + }, + + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + + // 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: [ + { + 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.', + }, + ], + ], + })), +});