From f9624f0cdab4c6de1fc221ab5b743bf9c080cc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 13 Apr 2026 17:21:10 +0200 Subject: [PATCH 1/2] Add autofix to template-no-unknown-arguments-for-builtin-components: rename args and migrate events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors upstream's autofix: - @elementId/@disabled/@class/@tabindex/etc. → HTML attribute rename - @click/@focus/etc. → {{on "event" expr}} modifier migration - Remove-only autofix for deprecated-but-unmigratable args (@tagName, @bubbles, @init) Truly unknown args still report without fix. --- ...nknown-arguments-for-builtin-components.js | 94 ++++++++++++++++++- ...nknown-arguments-for-builtin-components.js | 54 +++++++---- 2 files changed, 128 insertions(+), 20 deletions(-) diff --git a/lib/rules/template-no-unknown-arguments-for-builtin-components.js b/lib/rules/template-no-unknown-arguments-for-builtin-components.js index 5b2c36a793..043e73a110 100644 --- a/lib/rules/template-no-unknown-arguments-for-builtin-components.js +++ b/lib/rules/template-no-unknown-arguments-for-builtin-components.js @@ -382,6 +382,17 @@ function checkRequired(nodeMeta, node, seen, context) { } } +// Rename `@argName=value` to `newName=value` — strips the `@` and swaps +// the identifier. Used when a deprecated argument has a direct HTML +// attribute replacement (e.g. `@elementId` -> `id`). +function buildRenameFix(attr, newName) { + return (fixer) => { + const nameStart = attr.range[0]; + const nameEnd = nameStart + attr.name.length; + return fixer.replaceTextRange([nameStart, nameEnd], newName); + }; +} + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -392,7 +403,7 @@ module.exports = { url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unknown-arguments-for-builtin-components.md', templateMode: 'both', }, - fixable: null, + fixable: 'code', schema: [], messages: { unknownArgument: '{{message}}', @@ -410,6 +421,83 @@ module.exports = { create(context) { const sourceCode = context.sourceCode; + // Remove the attribute entirely (including any preceding whitespace that + // separates it from the previous token). + function buildRemovalFix(attr) { + return (fixer) => { + const text = sourceCode.getText(); + const attrStart = attr.range[0]; + const attrEnd = attr.range[1]; + + let removeStart = attrStart; + while (removeStart > 0 && /\s/.test(text[removeStart - 1])) { + removeStart--; + } + + return fixer.removeRange([removeStart, attrEnd]); + }; + } + + // Migrate `@eventName={{expr}}` to `{{on "htmlEvent" expr}}` modifier + // (or `{{on "htmlEvent" (helper ...params)}}` when the value is a call). + // Only safe when the attribute value is a mustache expression. + function buildEventMigrationFix(attr, htmlEventName) { + return (fixer) => { + const valueText = sourceCode.getText(attr.value); + // Strip outer `{{` and `}}` to get the expression text. + let inner = valueText; + if (inner.startsWith('{{') && inner.endsWith('}}')) { + inner = inner.slice(2, -2).trim(); + } + // If the value has parameters (e.g. `action this.click`), wrap as + // a sub-expression so the modifier receives a single callable. + const hasParams = + attr.value && + attr.value.type === 'GlimmerMustacheStatement' && + Array.isArray(attr.value.params) && + attr.value.params.length > 0; + const expr = hasParams ? `(${inner})` : inner; + const modifier = `{{on "${htmlEventName}" ${expr}}}`; + return fixer.replaceTextRange([attr.range[0], attr.range[1]], modifier); + }; + } + + function buildFix(node, attr) { + const tagMeta = KnownArguments[node.tag]; + if (!tagMeta) { + return null; + } + const deprecatedArgs = tagMeta.deprecatedArguments || {}; + const deprecatedEvents = tagMeta.deprecatedEvents || {}; + + if (attr.name in deprecatedArgs) { + const replacement = deprecatedArgs[attr.name]; + if (replacement) { + // Rename to the equivalent HTML attribute. + return buildRenameFix(attr, replacement); + } + // No replacement attribute — just remove the deprecated arg. + return buildRemovalFix(attr); + } + + if (attr.name in deprecatedEvents) { + const replacement = deprecatedEvents[attr.name]; + if (!replacement) { + // No replacement event (e.g. `@bubbles`) — just remove. + return buildRemovalFix(attr); + } + // Only migrate to `{{on}}` when the value is a mustache expression. + // Otherwise (string literal, valueless), leave unfixed. + if (attr.value && attr.value.type === 'GlimmerMustacheStatement') { + return buildEventMigrationFix(attr, replacement); + } + return null; + } + + // Truly unknown argument (typo) — no autofix. + return null; + } + return { GlimmerElementNode(node) { if (!node.tag || !node.attributes) { @@ -451,12 +539,14 @@ module.exports = { } } - // Report unknown/deprecated arguments + // Report unknown/deprecated arguments. for (const attr of warns) { + const fix = buildFix(node, attr); context.report({ node: attr, messageId: 'unknownArgument', data: { message: getErrorMessage(node.tag, attr.name) }, + fix: fix || null, }); } diff --git a/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js b/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js index 48f62799a0..aedabbac69 100644 --- a/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js +++ b/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js @@ -135,55 +135,72 @@ ruleTester.run('template-no-unknown-arguments-for-builtin-components', rule, { errors: [{ messageId: 'conflictArgument' }, { messageId: 'conflictArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument with replacement — autofixed by renaming to the HTML attribute. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated event with a helper invocation value — migrated to an `{{on}}` modifier with the helper as a sub-expression. code: '', - output: null, + output: + '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Two deprecated arguments on Input — both renamed to HTML attributes. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }, { messageId: 'unknownArgument' }], }, { + // Deprecated event with a simple path value — migrated to an `{{on}}` modifier. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '