@herb-tools/linter 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +2 -2
  2. package/dist/herb-lint.js +1512 -85
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +538 -72
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +465 -74
  7. package/dist/index.js.map +1 -1
  8. package/dist/lint-worker.js +1510 -83
  9. package/dist/lint-worker.js.map +1 -1
  10. package/dist/loader.cjs +1065 -81
  11. package/dist/loader.cjs.map +1 -1
  12. package/dist/loader.js +1044 -82
  13. package/dist/loader.js.map +1 -1
  14. package/dist/rules/actionview-no-silent-render.js +31 -0
  15. package/dist/rules/actionview-no-silent-render.js.map +1 -0
  16. package/dist/rules/erb-no-case-node-children.js +3 -1
  17. package/dist/rules/erb-no-case-node-children.js.map +1 -1
  18. package/dist/rules/erb-no-duplicate-branch-elements.js +95 -11
  19. package/dist/rules/erb-no-duplicate-branch-elements.js.map +1 -1
  20. package/dist/rules/erb-no-empty-control-flow.js +190 -0
  21. package/dist/rules/erb-no-empty-control-flow.js.map +1 -0
  22. package/dist/rules/erb-no-silent-statement.js +44 -0
  23. package/dist/rules/erb-no-silent-statement.js.map +1 -0
  24. package/dist/rules/erb-no-unsafe-script-interpolation.js +37 -3
  25. package/dist/rules/erb-no-unsafe-script-interpolation.js.map +1 -1
  26. package/dist/rules/html-allowed-script-type.js +1 -1
  27. package/dist/rules/html-allowed-script-type.js.map +1 -1
  28. package/dist/rules/index.js +20 -16
  29. package/dist/rules/index.js.map +1 -1
  30. package/dist/rules/rule-utils.js +13 -10
  31. package/dist/rules/rule-utils.js.map +1 -1
  32. package/dist/rules.js +8 -2
  33. package/dist/rules.js.map +1 -1
  34. package/dist/types/index.d.ts +1 -0
  35. package/dist/types/rules/actionview-no-silent-render.d.ts +9 -0
  36. package/dist/types/rules/erb-no-duplicate-branch-elements.d.ts +1 -0
  37. package/dist/types/rules/erb-no-empty-control-flow.d.ts +8 -0
  38. package/dist/types/rules/erb-no-silent-statement.d.ts +9 -0
  39. package/dist/types/rules/erb-no-unsafe-script-interpolation.d.ts +2 -1
  40. package/dist/types/rules/index.d.ts +20 -16
  41. package/dist/types/rules/rule-utils.d.ts +7 -6
  42. package/dist/types/types.d.ts +4 -3
  43. package/dist/types.js +6 -3
  44. package/dist/types.js.map +1 -1
  45. package/docs/rules/README.md +3 -0
  46. package/docs/rules/actionview-no-silent-render.md +47 -0
  47. package/docs/rules/erb-no-empty-control-flow.md +83 -0
  48. package/docs/rules/erb-no-silent-statement.md +53 -0
  49. package/docs/rules/erb-no-unsafe-script-interpolation.md +70 -3
  50. package/package.json +8 -8
  51. package/src/index.ts +21 -0
  52. package/src/rules/actionview-no-silent-render.ts +44 -0
  53. package/src/rules/erb-no-case-node-children.ts +3 -1
  54. package/src/rules/erb-no-duplicate-branch-elements.ts +130 -14
  55. package/src/rules/erb-no-empty-control-flow.ts +255 -0
  56. package/src/rules/erb-no-silent-statement.ts +58 -0
  57. package/src/rules/erb-no-unsafe-script-interpolation.ts +51 -5
  58. package/src/rules/html-allowed-script-type.ts +1 -1
  59. package/src/rules/index.ts +21 -16
  60. package/src/rules/rule-utils.ts +14 -10
  61. package/src/rules.ts +8 -2
  62. package/src/types.ts +7 -3
@@ -5,10 +5,12 @@ This page contains documentation for all Herb Linter rules.
5
5
  ## Available Rules
6
6
 
7
7
  - [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
8
+ - [`actionview-no-silent-render`](./actionview-no-silent-render.md) - Disallow calling `render` without outputting the result
8
9
  - [`erb-comment-syntax`](./erb-comment-syntax.md) - Disallow Ruby comments immediately after ERB tags
9
10
  - [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
10
11
  - [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
11
12
  - [`erb-no-duplicate-branch-elements`](./erb-no-duplicate-branch-elements.md) - Disallow duplicate elements across conditional branches
13
+ - [`erb-no-empty-control-flow`](./erb-no-empty-control-flow.md) - Disallow empty ERB control flow blocks
12
14
  - [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
13
15
  - [`erb-no-extra-newline`](./erb-no-extra-newline.md) - Disallow extra newlines.
14
16
  - [`erb-no-extra-whitespace-inside-tags`](./erb-no-extra-whitespace-inside-tags.md) - Disallow multiple consecutive spaces inside ERB tags
@@ -20,6 +22,7 @@ This page contains documentation for all Herb Linter rules.
20
22
  - [`erb-no-output-in-attribute-name`](./erb-no-output-in-attribute-name.md) - Disallow ERB output in attribute names
21
23
  - [`erb-no-output-in-attribute-position`](./erb-no-output-in-attribute-position.md) - Disallow ERB output in attribute position
22
24
  - [`erb-no-raw-output-in-attribute-value`](./erb-no-raw-output-in-attribute-value.md) - Disallow `<%==` in attribute values
25
+ - [`erb-no-silent-statement`](./erb-no-silent-statement.md) - Disallow silent ERB statements
23
26
  - [`erb-no-silent-tag-in-attribute-name`](./erb-no-silent-tag-in-attribute-name.md) - Disallow ERB silent tags in HTML attribute names
24
27
  - [`erb-no-statement-in-script`](./erb-no-statement-in-script.md) - Disallow ERB statements inside `<script>` tags
25
28
  - [`erb-no-then-in-control-flow`](./erb-no-then-in-control-flow.md) - Disallow `then` in ERB control flow expressions
@@ -0,0 +1,47 @@
1
+ # Linter Rule: Do not call `render` without rendering the result
2
+
3
+ **Rule:** `actionview-no-silent-render`
4
+
5
+ ## Description
6
+
7
+ Require that all `render` calls in ERB appear inside output tags (`<%= ... %>`), not control tags (`<% ... %>`). Otherwise, the call is evaluated but its result is silently discarded.
8
+
9
+ ## Rationale
10
+
11
+ Rails' `render` method returns HTML-safe strings meant to be included in the final response. If it's placed inside a non-output ERB tag (`<% render(...) %>`), the result is silently ignored. This is almost always a mistake and leads to confusion.
12
+
13
+ This rule catches these silent rendering issues and enforces that `render` is only used when its result is actually rendered.
14
+
15
+ ## Examples
16
+
17
+ ### ✅ Good
18
+
19
+ ```erb
20
+ <%= render "shared/error" %>
21
+ ```
22
+
23
+ ```erb
24
+ <%= render partial: "comment", collection: @comments %>
25
+ ```
26
+
27
+ ```erb
28
+ <%= render @product %>
29
+ ```
30
+
31
+ ### 🚫 Bad
32
+
33
+ ```erb
34
+ <% render "shared/error" %>
35
+ ```
36
+
37
+ ```erb
38
+ <% render partial: "comment", collection: @comments %>
39
+ ```
40
+
41
+ ```erb
42
+ <% render @product %>
43
+ ```
44
+
45
+ ## References
46
+
47
+ \-
@@ -0,0 +1,83 @@
1
+ # Linter Rule: Disallow empty ERB control flow blocks
2
+
3
+ **Rule:** `erb-no-empty-control-flow`
4
+
5
+ ## Description
6
+
7
+ Disallow empty ERB control flow blocks such as `if`, `elsif`, `else`, `unless`, `for`, `while`, `until`, `when`, `in`, `begin`, `rescue`, `ensure`, and `do` blocks.
8
+
9
+ Empty control flow blocks add unnecessary noise to templates and are often the result of incomplete refactoring, copy/paste errors, or forgotten placeholder code.
10
+
11
+ ## Rationale
12
+
13
+ An empty control flow block serves no purpose and makes templates harder to read. It often indicates code that was accidentally deleted, an incomplete refactoring where the logic was removed but the structure was left behind, or a placeholder that was never filled in.
14
+
15
+ This rule helps keep templates clean and intentional. Offenses are reported as hints with the `unnecessary` diagnostic tag, so editors like VS Code will render the empty blocks with faded text.
16
+
17
+ ## Examples
18
+
19
+ ### ✅ Good
20
+
21
+ ```erb
22
+ <% if condition %>
23
+ <p>Content</p>
24
+ <% end %>
25
+ ```
26
+
27
+ ```erb
28
+ <% case status %>
29
+ <% when "active" %>
30
+ <p>Active</p>
31
+ <% when "inactive" %>
32
+ <p>Inactive</p>
33
+ <% end %>
34
+ ```
35
+
36
+ ```erb
37
+ <% items.each do |item| %>
38
+ <p><%= item.name %></p>
39
+ <% end %>
40
+ ```
41
+
42
+ ### 🚫 Bad
43
+
44
+ ```erb
45
+ <% if condition %>
46
+ <% end %>
47
+ ```
48
+
49
+ ```erb
50
+ <% if condition %>
51
+ <p>Content</p>
52
+ <% else %>
53
+ <% end %>
54
+ ```
55
+
56
+ ```erb
57
+ <% case value %>
58
+ <% when "a" %>
59
+ <% when "b" %>
60
+ <p>B</p>
61
+ <% end %>
62
+ ```
63
+
64
+ ```erb
65
+ <% unless condition %>
66
+ <% end %>
67
+ ```
68
+
69
+ ```erb
70
+ <% items.each do |item| %>
71
+ <% end %>
72
+ ```
73
+
74
+ ```erb
75
+ <% begin %>
76
+ <p>Try this</p>
77
+ <% rescue %>
78
+ <% end %>
79
+ ```
80
+
81
+ ## References
82
+
83
+ \-
@@ -0,0 +1,53 @@
1
+ # Linter Rule: Disallow silent ERB statements
2
+
3
+ **Rule:** `erb-no-silent-statement`
4
+
5
+ ## Description
6
+
7
+ Disallow silent ERB tags (`<% %>`) that execute statements whose return value is discarded. Logic like method calls should live in controllers, helpers, or presenters, not in views. Assignments are allowed since they are pragmatic for DRYing up templates.
8
+
9
+ ## Rationale
10
+
11
+ Silent ERB tags that aren't control flow or assignments are a code smell. They execute Ruby code whose return value is silently discarded, which usually means the logic belongs in a controller, helper, or presenter rather than the view.
12
+
13
+ ## Examples
14
+
15
+ ### ✅ Good
16
+
17
+ ```erb
18
+ <%= title %>
19
+ <%= render "partial" %>
20
+ ```
21
+
22
+ ```erb
23
+ <% x = 1 %>
24
+ <% @title = "Hello" %>
25
+ <% x ||= default_value %>
26
+ <% x += 1 %>
27
+ ```
28
+
29
+ ```erb
30
+ <% if user.admin? %>
31
+ Admin tools
32
+ <% end %>
33
+ ```
34
+
35
+ ```erb
36
+ <% users.each do |user| %>
37
+ <p><%= user.name %></p>
38
+ <% end %>
39
+ ```
40
+
41
+ ### 🚫 Bad
42
+
43
+ ```erb
44
+ <% some_method %>
45
+ ```
46
+
47
+ ```erb
48
+ <% helper_call(arg) %>
49
+ ```
50
+
51
+ ## References
52
+
53
+ \-
@@ -4,11 +4,13 @@
4
4
 
5
5
  ## Description
6
6
 
7
- ERB interpolation in `<script>` tags must call `.to_json` to safely serialize Ruby data into JavaScript. Without `.to_json`, user-controlled values can break out of string literals and execute arbitrary JavaScript.
7
+ ERB interpolation in `<script>` tags must use `.to_json` to safely serialize Ruby data into JavaScript. Without `.to_json`, user-controlled values can break out of string literals and execute arbitrary JavaScript.
8
+
9
+ This rule also detects usage of `j()` and `escape_javascript()` inside `<script>` tags and recommends `.to_json` instead, because `j()` is only safe when the output is placed inside quoted string literals, a subtle requirement that is easy to get wrong.
8
10
 
9
11
  ## Rationale
10
12
 
11
- The main goal of this rule is to assert that Ruby data translates into JavaScript data, but never becomes JavaScript code. ERB output inside `<script>` tags is interpolated directly into the JavaScript context. Without proper serialization via `.to_json`, an attacker can inject arbitrary JavaScript by manipulating the interpolated value.
13
+ The main goal of this rule is to assert that Ruby data translates into JavaScript data, but never becomes JavaScript code. ERB output inside `<script>` tags is interpolated directly into the JavaScript context. Without proper serialization, an attacker can inject arbitrary JavaScript by manipulating the interpolated value.
12
14
 
13
15
  For example, consider:
14
16
 
@@ -18,7 +20,17 @@ For example, consider:
18
20
  </script>
19
21
  ```
20
22
 
21
- If `user.name` contains `"; alert(1); "`, the resulting JavaScript would execute arbitrary code. Using `.to_json` properly escapes the value and wraps it in quotes:
23
+ If `user.name` contains `"; alert(1); "`, it renders as:
24
+
25
+ ```html
26
+ <script>
27
+ var name = ""; alert(1); "";
28
+ </script>
29
+ ```
30
+
31
+ This is a Cross-Site Scripting (XSS) vulnerability, as the attacker breaks out of the string literal and executes arbitrary JavaScript.
32
+
33
+ Using `.to_json` properly escapes the value and wraps it in quotes:
22
34
 
23
35
  ```erb
24
36
  <script>
@@ -26,6 +38,47 @@ If `user.name` contains `"; alert(1); "`, the resulting JavaScript would execute
26
38
  </script>
27
39
  ```
28
40
 
41
+ With the same malicious input `"; alert(1); "`, `.to_json` safely renders:
42
+
43
+ ```html
44
+ <script>
45
+ var name = "\"; alert(1); \"";
46
+ </script>
47
+ ```
48
+
49
+ The value stays contained as a string, and no code is executed.
50
+
51
+ ### Why not `j()` or `escape_javascript()`?
52
+
53
+ `j()` escapes characters special inside JavaScript string literals (quotes, newlines, etc.), but it does **not** produce a quoted value. This means it's only safe when wrapped in quotes.
54
+
55
+ This works, but is fragile. In this example safety depends on the surrounding quotes:
56
+ ```erb
57
+ <script>
58
+ var name = '<%= j user.name %>';
59
+ </script>
60
+ ```
61
+
62
+ Without quotes, `j()` provides no protection and is **UNSAFE**, so code can still be injected:
63
+
64
+ ```erb
65
+ <script>
66
+ var name = <%= j user.name %>;
67
+ </script>
68
+ ```
69
+
70
+ If `user.name` is `alert(1)`, `j()` passes it through unchanged (no special characters to escape), rendering:
71
+
72
+ ```html
73
+ <script>
74
+ var name = alert(1);
75
+ </script>
76
+ ```
77
+
78
+ This results in a Cross-Site Scripting (XSS) vulnerability, as the attacker-controlled value is interpreted as JavaScript code rather than a string/data.
79
+
80
+ `.to_json` is safe in any position because it always produces a valid, quoted JavaScript value.
81
+
29
82
  ## Examples
30
83
 
31
84
  ### ✅ Good
@@ -68,6 +121,20 @@ If `user.name` contains `"; alert(1); "`, the resulting JavaScript would execute
68
121
  </script>
69
122
  ```
70
123
 
124
+ ### ⚠️ Prefer `.to_json` over `j()` / `escape_javascript()`
125
+
126
+ ```diff
127
+ - const url = '<%= j @my_path %>';
128
+ + const url = <%= @my_path.to_json %>;
129
+ ```
130
+
131
+ ```diff
132
+ - const name = '<%= escape_javascript(user.name) %>';
133
+ + const name = <%= user.name.to_json %>;
134
+ ```
135
+
71
136
  ## References
72
137
 
73
138
  - [Shopify/better-html — ScriptInterpolation](https://github.com/Shopify/better-html/blob/main/lib/better_html/test_helper/safe_erb/script_interpolation.rb)
139
+ - [`escape_javascript` / `j`](https://api.rubyonrails.org/classes/ActionView/Helpers/JavaScriptHelper.html#method-i-escape_javascript)
140
+ - [`json_escape`](https://api.rubyonrails.org/classes/ERB/Util.html#method-c-json_escape)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/linter",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "HTML+ERB linter for validating HTML structure and enforcing best practices",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -22,7 +22,7 @@
22
22
  "watch": "tsc -b -w",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest --watch",
25
- "prepublishOnly": "yarn clean && yarn build"
25
+ "prepublishOnly": "yarn clean && yarn build && yarn test"
26
26
  },
27
27
  "exports": {
28
28
  "./package.json": "./package.json",
@@ -46,12 +46,12 @@
46
46
  }
47
47
  },
48
48
  "dependencies": {
49
- "@herb-tools/config": "0.9.0",
50
- "@herb-tools/core": "0.9.0",
51
- "@herb-tools/highlighter": "0.9.0",
52
- "@herb-tools/node-wasm": "0.9.0",
53
- "@herb-tools/printer": "0.9.0",
54
- "@herb-tools/rewriter": "0.9.0",
49
+ "@herb-tools/config": "0.9.1",
50
+ "@herb-tools/core": "0.9.1",
51
+ "@herb-tools/highlighter": "0.9.1",
52
+ "@herb-tools/node-wasm": "0.9.1",
53
+ "@herb-tools/printer": "0.9.1",
54
+ "@herb-tools/rewriter": "0.9.1",
55
55
  "picomatch": "^4.0.2",
56
56
  "tinyglobby": "^0.2.15"
57
57
  },
package/src/index.ts CHANGED
@@ -4,3 +4,24 @@ export * from "./types.js"
4
4
 
5
5
  export { ruleDocumentationUrl } from "./urls.js"
6
6
  export { rules } from "./rules.js"
7
+
8
+ export {
9
+ findAttributeByName,
10
+ getAttribute,
11
+ getAttributeName,
12
+ getAttributes,
13
+ getAttributeValue,
14
+ getAttributeValueNodes,
15
+ getAttributeValueQuoteType,
16
+ getCombinedAttributeNameString,
17
+ getStaticAttributeValue,
18
+ getStaticAttributeValueContent,
19
+ getTagName,
20
+ hasAttribute,
21
+ hasAttributeValue,
22
+ hasDynamicAttributeName,
23
+ hasDynamicAttributeValue,
24
+ hasStaticAttributeValue,
25
+ hasStaticAttributeValueContent,
26
+ isAttributeValueQuoted,
27
+ } from "@herb-tools/core"
@@ -0,0 +1,44 @@
1
+ import { ParserRule } from "../types.js"
2
+ import { BaseRuleVisitor } from "./rule-utils.js"
3
+ import { isERBOutputNode } from "@herb-tools/core"
4
+
5
+ import type { ERBRenderNode, ParseResult, ParserOptions } from "@herb-tools/core"
6
+ import type { FullRuleConfig, LintContext, UnboundLintOffense } from "../types.js"
7
+
8
+ class ActionViewNoSilentRenderVisitor extends BaseRuleVisitor {
9
+ visitERBRenderNode(node: ERBRenderNode): void {
10
+ if (!isERBOutputNode(node)) {
11
+ this.addOffense(
12
+ `Avoid using \`${node.tag_opening?.value} %>\` with \`render\`. Use \`<%= %>\` to ensure the rendered content is output.`,
13
+ node.location,
14
+ )
15
+ }
16
+
17
+ this.visitChildNodes(node)
18
+ }
19
+ }
20
+
21
+ export class ActionViewNoSilentRenderRule extends ParserRule {
22
+ static ruleName = "actionview-no-silent-render"
23
+
24
+ get defaultConfig(): FullRuleConfig {
25
+ return {
26
+ enabled: true,
27
+ severity: "error"
28
+ }
29
+ }
30
+
31
+ get parserOptions(): Partial<ParserOptions> {
32
+ return {
33
+ render_nodes: true,
34
+ }
35
+ }
36
+
37
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
38
+ const visitor = new ActionViewNoSilentRenderVisitor(this.ruleName, context)
39
+
40
+ visitor.visit(result.value)
41
+
42
+ return visitor.offenses
43
+ }
44
+ }
@@ -28,10 +28,12 @@ class ERBNoCaseNodeChildrenVisitor extends BaseRuleVisitor {
28
28
  if (!this.isAllowedContent(child)) {
29
29
  const childCode = IdentityPrinter.print(child).trim()
30
30
 
31
- this.addOffense(
31
+ const offense = this.createOffense(
32
32
  `Do not place \`${childCode}\` between \`${caseCode}\` and \`${conditionCode}\`. Content here is not part of any branch and will not be rendered.`,
33
33
  child.location,
34
34
  )
35
+ offense.tags = ["unnecessary"]
36
+ this.offenses.push(offense)
35
37
  }
36
38
  }
37
39
  }
@@ -4,6 +4,7 @@ import { IdentityPrinter } from "@herb-tools/printer"
4
4
 
5
5
  import {
6
6
  isHTMLElementNode,
7
+ isHTMLOpenTagNode,
7
8
  isERBIfNode,
8
9
  isERBElseNode,
9
10
  isERBUnlessNode,
@@ -27,12 +28,21 @@ type ConditionalNode = ERBIfNode | ERBUnlessNode | ERBCaseNode
27
28
 
28
29
  interface DuplicateBranchAutofixContext extends BaseAutofixContext {
29
30
  node: Mutable<ConditionalNode>
31
+ allIdentical?: boolean
30
32
  }
31
33
 
32
34
  function getSignificantNodes(statements: Node[]): Node[] {
33
35
  return statements.filter(node => !isPureWhitespaceNode(node))
34
36
  }
35
37
 
38
+ function trimWhitespaceNodes(nodes: Node[]): Node[] {
39
+ let start = 0
40
+ let end = nodes.length
41
+ while (start < end && isPureWhitespaceNode(nodes[start])) start++
42
+ while (end > start && isPureWhitespaceNode(nodes[end - 1])) end--
43
+ return nodes.slice(start, end)
44
+ }
45
+
36
46
  function allEquivalentElements(nodes: Node[]): nodes is HTMLElementNode[] {
37
47
  if (nodes.length < 2) return false
38
48
  if (!nodes.every(node => isHTMLElementNode(node))) return false
@@ -172,10 +182,31 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
172
182
  this.markSubsequentIfNodesAsProcessed(node)
173
183
  }
174
184
 
185
+ if (this.allBranchesIdentical(branches)) {
186
+ this.addOffense(
187
+ "All branches of this conditional have identical content. The conditional can be removed.",
188
+ node.location,
189
+ { node: node as Mutable<ConditionalNode>, allIdentical: true },
190
+ "warning",
191
+ )
192
+
193
+ return
194
+ }
195
+
175
196
  const state = { isFirstOffense: true }
176
197
  this.checkBranches(branches, node, state)
177
198
  }
178
199
 
200
+ private allBranchesIdentical(branches: Node[][]): boolean {
201
+ if (branches.length < 2) return false
202
+
203
+ const first = branches[0].map(node => IdentityPrinter.print(node)).join("")
204
+
205
+ return branches.slice(1).every(branch =>
206
+ branch.map(node => IdentityPrinter.print(node)).join("") === first
207
+ )
208
+ }
209
+
179
210
  private markSubsequentIfNodesAsProcessed(node: ERBIfNode): void {
180
211
  let current: ERBIfNode | ERBElseNode | null = node.subsequent
181
212
 
@@ -214,17 +245,37 @@ class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor<DuplicateBranc
214
245
 
215
246
  for (const element of elements) {
216
247
  const printed = IdentityPrinter.print(element.open_tag)
217
- const autofixContext = state.isFirstOffense
218
- ? { node: conditionalNode as Mutable<ConditionalNode> }
219
- : undefined
220
248
 
221
- this.addOffense(
222
- `The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`,
223
- bodiesMatch ? element.location : (element?.open_tag?.location || element.location),
224
- autofixContext,
225
- )
249
+ if (bodiesMatch) {
250
+ const autofixContext = state.isFirstOffense
251
+ ? { node: conditionalNode as Mutable<ConditionalNode> }
252
+ : undefined
253
+
254
+ this.addOffense(
255
+ `The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`,
256
+ element.location,
257
+ autofixContext,
258
+ )
226
259
 
227
- state.isFirstOffense = false
260
+ state.isFirstOffense = false
261
+ } else {
262
+ const autofixContext = state.isFirstOffense
263
+ ? { node: conditionalNode as Mutable<ConditionalNode> }
264
+ : undefined
265
+
266
+ const tagNameLocation = isHTMLOpenTagNode(element.open_tag) && element.open_tag.tag_name?.location
267
+ ? element.open_tag.tag_name.location
268
+ : element?.open_tag?.location || element.location
269
+
270
+ this.addOffense(
271
+ `The \`${printed}\` tag is repeated across all branches with different content. Consider extracting the shared tag outside the conditional.`,
272
+ tagNameLocation,
273
+ autofixContext,
274
+ "hint",
275
+ )
276
+
277
+ state.isFirstOffense = false
278
+ }
228
279
  }
229
280
 
230
281
  if (!bodiesMatch && bodies.every(body => body.length > 0)) {
@@ -260,6 +311,18 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
260
311
  const branches = collectBranches(conditionalNode as ConditionalNode)
261
312
  if (!branches) return null
262
313
 
314
+ if (offense.autofixContext.allIdentical) {
315
+ const parentInfo = findParentArray(result.value, conditionalNode as unknown as Node)
316
+ if (!parentInfo) return null
317
+
318
+ const { array: parentArray, index: conditionalIndex } = parentInfo
319
+ const firstBranchContent = trimWhitespaceNodes(branches[0])
320
+
321
+ parentArray.splice(conditionalIndex, 1, ...firstBranchContent)
322
+
323
+ return result
324
+ }
325
+
263
326
  const significantBranches = branches.map(getSignificantNodes)
264
327
  if (significantBranches.some(branch => branch.length === 0)) return null
265
328
 
@@ -274,24 +337,57 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
274
337
 
275
338
  let { array: parentArray, index: conditionalIndex } = parentInfo
276
339
  let hasWrapped = false
340
+ let didMutate = false
341
+ let failedToHoistPrefix = false
342
+ let hoistedBefore = false
277
343
 
278
344
  const hoistElement = (elements: HTMLElementNode[], position: "before" | "after"): void => {
345
+ const actualPosition = (position === "before" && failedToHoistPrefix) ? "after" : position
279
346
  const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]))
280
347
 
281
348
  if (bodiesMatch) {
349
+ if (actualPosition === "after") {
350
+ const currentLengths = branches.map(b => getSignificantNodes(b as Node[]).length)
351
+ if (currentLengths.some(l => l !== currentLengths[0])) return
352
+ }
353
+
354
+ if (actualPosition === "after" && position === "before") {
355
+ const isAtEnd = branches.every((branch, index) => {
356
+ const nodes = getSignificantNodes(branch as Node[])
357
+
358
+ return nodes.length > 0 && nodes[nodes.length - 1] === elements[index]
359
+ })
360
+
361
+ if (!isAtEnd) return
362
+ }
363
+
282
364
  for (let i = 0; i < branches.length; i++) {
283
365
  removeNodeFromArray(branches[i] as Node[], elements[i])
284
366
  }
285
367
 
286
- if (position === "before") {
287
- parentArray.splice(conditionalIndex, 0, elements[0])
288
- conditionalIndex++
368
+ if (actualPosition === "before") {
369
+ parentArray.splice(conditionalIndex, 0, elements[0], createLiteral("\n"))
370
+ conditionalIndex += 2
371
+ hoistedBefore = true
289
372
  } else {
290
- parentArray.splice(conditionalIndex + 1, 0, elements[0])
373
+ parentArray.splice(conditionalIndex + 1, 0, createLiteral("\n"), elements[0])
291
374
  }
375
+
376
+ didMutate = true
292
377
  } else {
293
378
  if (hasWrapped) return
294
379
 
380
+ const canWrap = branches.every((branch, index) => {
381
+ const remaining = getSignificantNodes(branch)
382
+
383
+ return remaining.length === 1 && remaining[0] === elements[index]
384
+ })
385
+
386
+ if (!canWrap) {
387
+ if (position === "before") failedToHoistPrefix = true
388
+ return
389
+ }
390
+
295
391
  for (let i = 0; i < branches.length; i++) {
296
392
  replaceNodeWithBody(branches[i] as Node[], elements[i])
297
393
  }
@@ -302,6 +398,7 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
302
398
  parentArray = wrapper.body as Node[]
303
399
  conditionalIndex = 1
304
400
  hasWrapped = true
401
+ didMutate = true
305
402
  }
306
403
  }
307
404
 
@@ -315,6 +412,25 @@ export class ERBNoDuplicateBranchElementsRule extends ParserRule<DuplicateBranch
315
412
  hoistElement(elements, "after")
316
413
  }
317
414
 
318
- return result
415
+ if (!hasWrapped && hoistedBefore) {
416
+ const remaining = branches.map(branch => getSignificantNodes(branch as Node[]))
417
+
418
+ if (remaining.every(branch => branch.length === 1) && allEquivalentElements(remaining.map(b => b[0]))) {
419
+ const elements = remaining.map(b => b[0] as HTMLElementNode)
420
+ const bodiesMatch = elements.every(el => IdentityPrinter.print(el) === IdentityPrinter.print(elements[0]))
421
+
422
+ if (!bodiesMatch && elements.every(el => el.body.length > 0)) {
423
+ for (let i = 0; i < branches.length; i++) {
424
+ replaceNodeWithBody(branches[i] as Node[], elements[i])
425
+ }
426
+
427
+ const wrapper = createWrapper(elements[0], [createLiteral("\n"), conditionalNode as unknown as Node, createLiteral("\n")])
428
+ parentArray[conditionalIndex] = wrapper
429
+ didMutate = true
430
+ }
431
+ }
432
+ }
433
+
434
+ return didMutate ? result : null
319
435
  }
320
436
  }