From 4dbe5ac955291d5177931e346cda58fc6b07a6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sun, 12 Apr 2026 22:58:07 +0200 Subject: [PATCH 1/2] Fix template-no-unnecessary-component-helper: skip invalid-identifier autofix in GJS/GTS The rule converts {{component "my-component" ...}} to {{my-component ...}}. In GJS/GTS, a kebab-case component name is not a valid JS identifier, so the autofix would produce unparseable output. Keep detection (the user is still told to invoke directly), but skip the autofix in strict mode when the component name contains non-identifier characters. --- ...emplate-no-unnecessary-component-helper.js | 35 ++++++++++++++----- ...emplate-no-unnecessary-component-helper.js | 25 ++++++++++++- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/lib/rules/template-no-unnecessary-component-helper.js b/lib/rules/template-no-unnecessary-component-helper.js index 9a2f67bf4f..c445227277 100644 --- a/lib/rules/template-no-unnecessary-component-helper.js +++ b/lib/rules/template-no-unnecessary-component-helper.js @@ -59,8 +59,21 @@ module.exports = { create(context) { const sourceCode = context.sourceCode; + const filename = context.filename; + const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts'); let inAttribute = 0; + // In strict mode, a kebab-case / slash component name cannot become + // a bare mustache invocation — the resulting identifier would not be + // a valid JS binding. Detect the rule's violation, but skip autofix + // when it would produce unparseable GJS/GTS output. + function canAutofix(componentName) { + if (!isStrictMode) { + return true; + } + return /^[$A-Z_a-z][\w$]*$/.test(componentName); + } + return { GlimmerAttrNode() { inAttribute++; @@ -79,13 +92,14 @@ module.exports = { const componentName = node.params[0].value || node.params[0].original; const invocation = getComponentInvocationText(sourceCode, node, componentName); - context.report({ + const report = { node, messageId: 'noUnnecessaryComponent', - fix(fixer) { - return fixer.replaceText(node, `{{${invocation}}}`); - }, - }); + }; + if (canAutofix(componentName)) { + report.fix = (fixer) => fixer.replaceText(node, `{{${invocation}}}`); + } + context.report(report); }, GlimmerBlockStatement(node) { @@ -99,10 +113,12 @@ module.exports = { const componentName = node.params[0].value || node.params[0].original; const invocation = getComponentInvocationText(sourceCode, node, componentName); - context.report({ + const report = { node, messageId: 'noUnnecessaryComponent', - fix(fixer) { + }; + if (canAutofix(componentName)) { + report.fix = (fixer) => { const openInvocationEnd = getOpenInvocationEnd(node); const closingPathEnd = node.range[1] - 2; const closingPathStart = closingPathEnd - node.path.original.length; @@ -111,8 +127,9 @@ module.exports = { fixer.replaceTextRange([node.path.range[0], openInvocationEnd], invocation), fixer.replaceTextRange([closingPathStart, closingPathEnd], componentName), ]; - }, - }); + }; + } + context.report(report); }, }; }, diff --git a/tests/lib/rules/template-no-unnecessary-component-helper.js b/tests/lib/rules/template-no-unnecessary-component-helper.js index 4a7344f457..a9fc324292 100644 --- a/tests/lib/rules/template-no-unnecessary-component-helper.js +++ b/tests/lib/rules/template-no-unnecessary-component-helper.js @@ -91,7 +91,30 @@ const gjsRuleTester = new RuleTester({ gjsRuleTester.run('template-no-unnecessary-component-helper', rule, { valid: validGjs, - invalid: invalidHbs.map(wrapTemplate), + invalid: [ + ...invalidHbs.map(wrapTemplate), + // GJS/GTS: autofix is skipped when the component name isn't a valid JS + // identifier. The error is still reported so the user sees the issue. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'noUnnecessaryComponent' }], + }, + { + filename: 'test.gts', + code: '', + output: null, + errors: [{ messageId: 'noUnnecessaryComponent' }], + }, + // GJS/GTS: valid JS identifier → autofix still applies + { + filename: 'test.gjs', + code: '', + output: '', + errors: [{ messageId: 'noUnnecessaryComponent' }], + }, + ], }); const hbsRuleTester = new RuleTester({ From 575e867b75641095ce0442b59148e4666f4badd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 13 Apr 2026 20:53:08 +0200 Subject: [PATCH 2/2] Add informative message for kebab-case components in GJS/GTS Kebab-case names can't become valid JS bindings, so a full autofix (component helper removal + import) isn't feasible here. Report with a dedicated messageId that tells the user the PascalCase equivalent and points to ember-codemods/angle-brackets-codemod for automated end-to-end migration. --- ...emplate-no-unnecessary-component-helper.js | 63 +++++++++++-------- ...emplate-no-unnecessary-component-helper.js | 10 +-- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/lib/rules/template-no-unnecessary-component-helper.js b/lib/rules/template-no-unnecessary-component-helper.js index c445227277..4fd60988cf 100644 --- a/lib/rules/template-no-unnecessary-component-helper.js +++ b/lib/rules/template-no-unnecessary-component-helper.js @@ -1,3 +1,14 @@ +function toPascalCase(name) { + return name + .split(/[/-]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +function isValidIdentifier(name) { + return /^[$A-Z_a-z][\w$]*$/.test(name); +} + function isComponentWithStringLiteral(node) { return ( node.path && @@ -48,6 +59,10 @@ module.exports = { schema: [], messages: { noUnnecessaryComponent: 'Invoke component directly instead of using `component` helper', + noUnnecessaryComponentKebab: + 'In GJS/GTS, "{{name}}" must be imported as a JS binding (e.g. `import {{pascal}} from "..."`). ' + + 'Invoke it directly as `<{{pascal}}>` instead of via the `component` helper. ' + + 'The ember-codemods angle-brackets-codemod can automate this migration.', }, originallyFrom: { name: 'ember-template-lint', @@ -63,15 +78,23 @@ module.exports = { const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts'); let inAttribute = 0; - // In strict mode, a kebab-case / slash component name cannot become - // a bare mustache invocation — the resulting identifier would not be - // a valid JS binding. Detect the rule's violation, but skip autofix - // when it would produce unparseable GJS/GTS output. - function canAutofix(componentName) { - if (!isStrictMode) { - return true; + // In strict mode, a kebab-case / slash component name cannot become a bare + // mustache invocation — the result would not be a valid JS binding and would + // require an import. Ecosystem tooling (ember-codemods/angle-brackets-codemod) + // handles this migration end-to-end including adding the import. + function buildReport(node, componentName, fix) { + if (isStrictMode && !isValidIdentifier(componentName)) { + return { + node, + messageId: 'noUnnecessaryComponentKebab', + data: { name: componentName, pascal: toPascalCase(componentName) }, + }; + } + const report = { node, messageId: 'noUnnecessaryComponent' }; + if (!isStrictMode || isValidIdentifier(componentName)) { + report.fix = fix; } - return /^[$A-Z_a-z][\w$]*$/.test(componentName); + return report; } return { @@ -92,14 +115,9 @@ module.exports = { const componentName = node.params[0].value || node.params[0].original; const invocation = getComponentInvocationText(sourceCode, node, componentName); - const report = { - node, - messageId: 'noUnnecessaryComponent', - }; - if (canAutofix(componentName)) { - report.fix = (fixer) => fixer.replaceText(node, `{{${invocation}}}`); - } - context.report(report); + context.report( + buildReport(node, componentName, (fixer) => fixer.replaceText(node, `{{${invocation}}}`)) + ); }, GlimmerBlockStatement(node) { @@ -113,12 +131,8 @@ module.exports = { const componentName = node.params[0].value || node.params[0].original; const invocation = getComponentInvocationText(sourceCode, node, componentName); - const report = { - node, - messageId: 'noUnnecessaryComponent', - }; - if (canAutofix(componentName)) { - report.fix = (fixer) => { + context.report( + buildReport(node, componentName, (fixer) => { const openInvocationEnd = getOpenInvocationEnd(node); const closingPathEnd = node.range[1] - 2; const closingPathStart = closingPathEnd - node.path.original.length; @@ -127,9 +141,8 @@ module.exports = { fixer.replaceTextRange([node.path.range[0], openInvocationEnd], invocation), fixer.replaceTextRange([closingPathStart, closingPathEnd], componentName), ]; - }; - } - context.report(report); + }) + ); }, }; }, diff --git a/tests/lib/rules/template-no-unnecessary-component-helper.js b/tests/lib/rules/template-no-unnecessary-component-helper.js index a9fc324292..217b5d2b35 100644 --- a/tests/lib/rules/template-no-unnecessary-component-helper.js +++ b/tests/lib/rules/template-no-unnecessary-component-helper.js @@ -93,19 +93,21 @@ gjsRuleTester.run('template-no-unnecessary-component-helper', rule, { valid: validGjs, invalid: [ ...invalidHbs.map(wrapTemplate), - // GJS/GTS: autofix is skipped when the component name isn't a valid JS - // identifier. The error is still reported so the user sees the issue. + // GJS/GTS: kebab-case names can't be valid JS identifiers — report with + // a dedicated message suggesting the PascalCase form and import. + // Full migration (including adding the import) is best handled by + // ember-codemods/angle-brackets-codemod. { filename: 'test.gjs', code: '', output: null, - errors: [{ messageId: 'noUnnecessaryComponent' }], + errors: [{ messageId: 'noUnnecessaryComponentKebab' }], }, { filename: 'test.gts', code: '', output: null, - errors: [{ messageId: 'noUnnecessaryComponent' }], + errors: [{ messageId: 'noUnnecessaryComponentKebab' }], }, // GJS/GTS: valid JS identifier → autofix still applies {