@herb-tools/linter 0.9.0 → 0.9.2

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 +1525 -98
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +546 -87
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +465 -87
  7. package/dist/index.js.map +1 -1
  8. package/dist/lint-worker.js +1523 -96
  9. package/dist/lint-worker.js.map +1 -1
  10. package/dist/loader.cjs +1078 -94
  11. package/dist/loader.cjs.map +1 -1
  12. package/dist/loader.js +1057 -95
  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 +14 -23
  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 +8 -11
  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 +15 -24
  61. package/src/rules.ts +8 -2
  62. package/src/types.ts +7 -3
package/dist/types.js CHANGED
@@ -31,7 +31,7 @@ export class ParserRule {
31
31
  get parserOptions() {
32
32
  return DEFAULT_LINTER_PARSER_OPTIONS;
33
33
  }
34
- createOffense(message, location, autofixContext, severity) {
34
+ createOffense(message, location, autofixContext, severity, tags) {
35
35
  return {
36
36
  rule: this.ruleName,
37
37
  code: this.ruleName,
@@ -40,6 +40,7 @@ export class ParserRule {
40
40
  location,
41
41
  autofixContext,
42
42
  severity,
43
+ tags,
43
44
  };
44
45
  }
45
46
  }
@@ -59,7 +60,7 @@ export class LexerRule {
59
60
  get defaultConfig() {
60
61
  return DEFAULT_RULE_CONFIG;
61
62
  }
62
- createOffense(message, location, autofixContext, severity) {
63
+ createOffense(message, location, autofixContext, severity, tags) {
63
64
  return {
64
65
  rule: this.ruleName,
65
66
  code: this.ruleName,
@@ -68,6 +69,7 @@ export class LexerRule {
68
69
  location,
69
70
  autofixContext,
70
71
  severity,
72
+ tags,
71
73
  };
72
74
  }
73
75
  }
@@ -93,7 +95,7 @@ export class SourceRule {
93
95
  get defaultConfig() {
94
96
  return DEFAULT_RULE_CONFIG;
95
97
  }
96
- createOffense(message, location, autofixContext, severity) {
98
+ createOffense(message, location, autofixContext, severity, tags) {
97
99
  return {
98
100
  rule: this.ruleName,
99
101
  code: this.ruleName,
@@ -102,6 +104,7 @@ export class SourceRule {
102
104
  location,
103
105
  autofixContext,
104
106
  severity,
107
+ tags,
105
108
  };
106
109
  }
107
110
  }
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,6BAA6B,GAA2B;IACnE,gBAAgB,EAAE,IAAI;CACvB,CAAA;AAiED;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAmB;IACjD,OAAO,EAAE,IAAI;IACb,QAAQ,EAAE,OAAO;IACjB,OAAO,EAAE,EAAE;CACZ,CAAA;AAED;;GAEG;AACH,MAAM,OAAgB,UAAU;IAC9B,MAAM,CAAC,IAAI,GAAG,QAAiB,CAAA;IAC/B,MAAM,CAAC,QAAQ,CAAQ;IACvB,uEAAuE;IACvE,MAAM,CAAC,eAAe,GAAG,KAAK,CAAA;IAC9B,wGAAwG;IACxG,MAAM,CAAC,qBAAqB,GAAG,KAAK,CAAA;IACpC,2FAA2F;IAC3F,MAAM,CAAC,oBAAoB,GAAG,KAAK,CAAA;IAEnC,IAAI,QAAQ;QACV,OAAQ,IAAI,CAAC,WAAiC,CAAC,QAAQ,CAAA;IACzD,CAAC;IAED,IAAI,aAAa;QACf,OAAO,mBAAmB,CAAA;IAC5B,CAAC;IAED,IAAI,aAAa;QACf,OAAO,6BAA6B,CAAA;IACtC,CAAC;IAES,aAAa,CAAC,OAAe,EAAE,QAAkB,EAAE,cAAgC,EAAE,QAAuB;QACpH,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,MAAM,EAAE,aAAa;YACrB,OAAO;YACP,QAAQ;YACR,cAAc;YACd,QAAQ;SACT,CAAA;IACH,CAAC;;AAwBH;;GAEG;AACH,MAAM,OAAgB,SAAS;IAC7B,MAAM,CAAC,IAAI,GAAG,OAAgB,CAAA;IAC9B,MAAM,CAAC,QAAQ,CAAQ;IACvB,uEAAuE;IACvE,MAAM,CAAC,eAAe,GAAG,KAAK,CAAA;IAC9B,wGAAwG;IACxG,MAAM,CAAC,qBAAqB,GAAG,KAAK,CAAA;IAEpC,IAAI,QAAQ;QACV,OAAQ,IAAI,CAAC,WAAgC,CAAC,QAAQ,CAAA;IACxD,CAAC;IAED,IAAI,aAAa;QACf,OAAO,mBAAmB,CAAA;IAC5B,CAAC;IAES,aAAa,CAAC,OAAe,EAAE,QAAkB,EAAE,cAAgC,EAAE,QAAuB;QACpH,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,MAAM,EAAE,aAAa;YACrB,OAAO;YACP,QAAQ;YACR,cAAc;YACd,QAAQ;SACT,CAAA;IACH,CAAC;;AAyCH;;GAEG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAgB;IAC/C,QAAQ,EAAE,SAAS;IACnB,cAAc,EAAE,SAAS;IACzB,qBAAqB,EAAE,SAAS;IAChC,qBAAqB,EAAE,SAAS;CACxB,CAAA;AAEV,MAAM,OAAgB,UAAU;IAC9B,MAAM,CAAC,IAAI,GAAG,QAAiB,CAAA;IAC/B,MAAM,CAAC,QAAQ,CAAQ;IACvB,uEAAuE;IACvE,MAAM,CAAC,eAAe,GAAG,KAAK,CAAA;IAC9B,wGAAwG;IACxG,MAAM,CAAC,qBAAqB,GAAG,KAAK,CAAA;IAEpC,IAAI,QAAQ;QACV,OAAQ,IAAI,CAAC,WAAiC,CAAC,QAAQ,CAAA;IACzD,CAAC;IAED,IAAI,aAAa;QACf,OAAO,mBAAmB,CAAA;IAC5B,CAAC;IAES,aAAa,CAAC,OAAe,EAAE,QAAkB,EAAE,cAAgC,EAAE,QAAuB;QACpH,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,IAAI,EAAE,IAAI,CAAC,QAAQ;YACnB,MAAM,EAAE,aAAa;YACrB,OAAO;YACP,QAAQ;YACR,cAAc;YACd,QAAQ;SACT,CAAA;IACH,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -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.2",
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.2",
50
+ "@herb-tools/core": "0.9.2",
51
+ "@herb-tools/highlighter": "0.9.2",
52
+ "@herb-tools/node-wasm": "0.9.2",
53
+ "@herb-tools/printer": "0.9.2",
54
+ "@herb-tools/rewriter": "0.9.2",
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
  }