From 2376eaf208b7f7fa310fa6254649ba73d67dd817 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 18 Mar 2026 18:27:05 -0400
Subject: [PATCH] Extract rule: template-simple-unless
---
README.md | 1 +
docs/rules/template-simple-unless.md | 71 ++++
lib/rules/template-simple-unless.js | 155 ++++++++
tests/lib/rules/template-simple-unless.js | 442 ++++++++++++++++++++++
4 files changed, 669 insertions(+)
create mode 100644 docs/rules/template-simple-unless.md
create mode 100644 lib/rules/template-simple-unless.js
create mode 100644 tests/lib/rules/template-simple-unless.js
diff --git a/README.md b/README.md
index fe88a45638..640e28aa5a 100644
--- a/README.md
+++ b/README.md
@@ -245,6 +245,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
+| [template-simple-unless](docs/rules/template-simple-unless.md) | require simple conditions in unless blocks | | | |
| [template-splat-attributes-only](docs/rules/template-splat-attributes-only.md) | disallow ...spread other than ...attributes | | | |
| [template-style-concatenation](docs/rules/template-style-concatenation.md) | disallow string concatenation in inline styles | | | |
diff --git a/docs/rules/template-simple-unless.md b/docs/rules/template-simple-unless.md
new file mode 100644
index 0000000000..c239b7dd8c
--- /dev/null
+++ b/docs/rules/template-simple-unless.md
@@ -0,0 +1,71 @@
+# ember/template-simple-unless
+
+
+
+Require simple conditions in `{{#unless}}` blocks. Complex expressions should use `{{#if}}` with negation instead.
+
+## Rule Details
+
+This rule enforces using simple property paths in `{{#unless}}` blocks rather than complex helper expressions.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```gjs
+
+ {{#unless (or (eq a 1) (gt b 2))}}
+ Complex condition
+ {{/unless}}
+
+```
+
+```gjs
+
+ {{#unless (and isAdmin (not isBanned))}}
+ Not allowed
+ {{/unless}}
+
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+
+ {{#unless isHidden}}
+ Visible
+ {{/unless}}
+
+```
+
+```gjs
+
+ {{#unless (eq value 1)}}
+ Not one
+ {{/unless}}
+
+```
+
+```gjs
+
+ {{#if (not (or a b))}}
+ Neither
+ {{/if}}
+
+```
+
+## Options
+
+| Name | Type | Default | Description |
+| ------------ | ---------- | ------- | --------------------------------------------------------------------------- |
+| `allowlist` | `string[]` | `[]` | Helper names allowed inside `{{unless}}`. |
+| `denylist` | `string[]` | `[]` | Helper names explicitly denied inside `{{unless}}`. |
+| `maxHelpers` | `integer` | `1` | Maximum number of helpers allowed inside `{{unless}}` (`-1` for unlimited). |
+
+## Related Rules
+
+- [no-negated-condition](template-no-negated-condition.md)
+
+## References
+
+- [Wikipedia/boolean algebra](https://en.wikipedia.org/wiki/Boolean_algebra)
diff --git a/lib/rules/template-simple-unless.js b/lib/rules/template-simple-unless.js
new file mode 100644
index 0000000000..b9400020e2
--- /dev/null
+++ b/lib/rules/template-simple-unless.js
@@ -0,0 +1,155 @@
+function isUnless(node) {
+ return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unless';
+}
+
+function isIf(node) {
+ return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'if';
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'require simple conditions in unless blocks',
+ category: 'Best Practices',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-simple-unless.md',
+ templateMode: 'both',
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ allowlist: { type: 'array', items: { type: 'string' } },
+ denylist: { type: 'array', items: { type: 'string' } },
+ maxHelpers: { type: 'integer' },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ followingElseBlock: 'Using an `else` block with `unless` should be avoided.',
+ asElseUnlessBlock: 'Using an `else unless` block should be avoided.',
+ withHelper: '{{message}}',
+ },
+ originallyFrom: {
+ name: 'ember-template-lint',
+ rule: 'lib/rules/simple-unless.js',
+ docs: 'docs/rule/simple-unless.md',
+ tests: 'test/unit/rules/simple-unless-test.js',
+ },
+ },
+
+ create(context) {
+ const options = context.options[0] || {};
+ const allowlist = options.allowlist || [];
+ const denylist = options.denylist || [];
+ const maxHelpers = options.maxHelpers === undefined ? 1 : options.maxHelpers;
+ const sourceCode = context.getSourceCode();
+
+ function isElseUnlessBlock(node) {
+ if (!node) {
+ return false;
+ }
+ if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unless') {
+ const text = sourceCode.getText(node);
+ return text.startsWith('{{else ');
+ }
+ return false;
+ }
+
+ function checkWithHelper(node) {
+ let helperCount = 0;
+ let nextParams = node.params || [];
+
+ do {
+ const currentParams = nextParams;
+ nextParams = [];
+
+ for (const param of currentParams) {
+ if (param.type === 'GlimmerSubExpression') {
+ helperCount++;
+ const helperName = param.path?.original || '';
+
+ if (maxHelpers > -1 && helperCount > maxHelpers) {
+ context.report({
+ node: param,
+ messageId: 'withHelper',
+ data: {
+ message: `Using {{unless}} in combination with other helpers should be avoided. MaxHelpers: ${maxHelpers}`,
+ },
+ });
+ return;
+ }
+
+ if (allowlist.length > 0 && !allowlist.includes(helperName)) {
+ context.report({
+ node: param,
+ messageId: 'withHelper',
+ data: {
+ message: `Using {{unless}} in combination with other helpers should be avoided. Allowed helper${allowlist.length > 1 ? 's' : ''}: ${allowlist}`,
+ },
+ });
+ return;
+ }
+
+ if (denylist.length > 0 && denylist.includes(helperName)) {
+ context.report({
+ node: param,
+ messageId: 'withHelper',
+ data: {
+ message: `Using {{unless}} in combination with other helpers should be avoided. Restricted helper${denylist.length > 1 ? 's' : ''}: ${denylist}`,
+ },
+ });
+ return;
+ }
+
+ if (param.params) {
+ nextParams.push(...param.params);
+ }
+ }
+ }
+ } while (nextParams.some((p) => p.type === 'GlimmerSubExpression'));
+ }
+
+ return {
+ GlimmerMustacheStatement(node) {
+ if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unless') {
+ if (node.params?.[0]?.path) {
+ checkWithHelper(node);
+ }
+ }
+ },
+
+ GlimmerBlockStatement(node) {
+ const nodeInverse = node.inverse;
+
+ if (nodeInverse && nodeInverse.body?.length > 0) {
+ if (isUnless(node)) {
+ // Check for {{#unless}}...{{else if}}
+ if (nodeInverse.body[0] && isIf(nodeInverse.body[0])) {
+ context.report({
+ node: node.program || node,
+ messageId: 'followingElseBlock',
+ });
+ } else {
+ // {{#unless}}...{{else}}
+ context.report({
+ node: node.program || node,
+ messageId: 'followingElseBlock',
+ });
+ }
+ } else if (isElseUnlessBlock(nodeInverse.body[0])) {
+ // {{#if}}...{{else unless}}
+ context.report({
+ node: nodeInverse.body[0],
+ messageId: 'asElseUnlessBlock',
+ });
+ }
+ } else if (isUnless(node) && node.params?.[0]?.path) {
+ checkWithHelper(node);
+ }
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-simple-unless.js b/tests/lib/rules/template-simple-unless.js
new file mode 100644
index 0000000000..459ccea02f
--- /dev/null
+++ b/tests/lib/rules/template-simple-unless.js
@@ -0,0 +1,442 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/template-simple-unless');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-simple-unless', rule, {
+ valid: [
+ '{{#unless isHidden}}Show{{/unless}}',
+ '{{#unless @disabled}}Enabled{{/unless}}',
+
+ "{{#unless isRed}}I'm blue, da ba dee da ba daa{{/unless}}",
+ '',
+ '',
+ '{{unrelated-mustache-without-params}}',
+ '{{#if foo}}{{else}}{{/if}}',
+ '{{#if foo}}{{else}}{{#unless bar}}{{/unless}}{{/if}}',
+ '{{#if foo}}{{else}}{{unless bar someProperty}}{{/if}}',
+ '{{#unless (or foo bar)}}order whiskey{{/unless}}',
+ {
+ code: '{{#unless (eq (or foo bar) baz)}}order whiskey{{/unless}}',
+ options: [{ maxHelpers: 2 }],
+ },
+ '{{#unless hamburger}}\\n HOT DOG!\\n{{/unless}}',
+ ],
+ invalid: [
+ {
+ code: '{{#unless (eq value 1)}}Not one{{/unless}}',
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+ {
+ code: '{{#unless (or a b)}}Neither{{/unless}}',
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+
+ {
+ code: "{{unless (if true) 'Please no'}}",
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+ {
+ code: "{{unless (and isBad isAwful) 'notBadAndAwful'}}",
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+ {
+ code: '',
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+ {
+ code: '
',
+ output: null,
+ options: [{ maxHelpers: 0 }],
+ errors: [{ messageId: 'withHelper' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-simple-unless', rule, {
+ valid: [
+ "{{#unless isRed}}I'm blue, da ba dee da ba daa{{/unless}}",
+ '