@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.
- package/README.md +2 -2
- package/dist/herb-lint.js +1525 -98
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +546 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +465 -87
- package/dist/index.js.map +1 -1
- package/dist/lint-worker.js +1523 -96
- package/dist/lint-worker.js.map +1 -1
- package/dist/loader.cjs +1078 -94
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +1057 -95
- package/dist/loader.js.map +1 -1
- package/dist/rules/actionview-no-silent-render.js +31 -0
- package/dist/rules/actionview-no-silent-render.js.map +1 -0
- package/dist/rules/erb-no-case-node-children.js +3 -1
- package/dist/rules/erb-no-case-node-children.js.map +1 -1
- package/dist/rules/erb-no-duplicate-branch-elements.js +95 -11
- package/dist/rules/erb-no-duplicate-branch-elements.js.map +1 -1
- package/dist/rules/erb-no-empty-control-flow.js +190 -0
- package/dist/rules/erb-no-empty-control-flow.js.map +1 -0
- package/dist/rules/erb-no-silent-statement.js +44 -0
- package/dist/rules/erb-no-silent-statement.js.map +1 -0
- package/dist/rules/erb-no-unsafe-script-interpolation.js +37 -3
- package/dist/rules/erb-no-unsafe-script-interpolation.js.map +1 -1
- package/dist/rules/html-allowed-script-type.js +1 -1
- package/dist/rules/html-allowed-script-type.js.map +1 -1
- package/dist/rules/index.js +20 -16
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/rule-utils.js +14 -23
- package/dist/rules/rule-utils.js.map +1 -1
- package/dist/rules.js +8 -2
- package/dist/rules.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/rules/actionview-no-silent-render.d.ts +9 -0
- package/dist/types/rules/erb-no-duplicate-branch-elements.d.ts +1 -0
- package/dist/types/rules/erb-no-empty-control-flow.d.ts +8 -0
- package/dist/types/rules/erb-no-silent-statement.d.ts +9 -0
- package/dist/types/rules/erb-no-unsafe-script-interpolation.d.ts +2 -1
- package/dist/types/rules/index.d.ts +20 -16
- package/dist/types/rules/rule-utils.d.ts +8 -11
- package/dist/types/types.d.ts +4 -3
- package/dist/types.js +6 -3
- package/dist/types.js.map +1 -1
- package/docs/rules/README.md +3 -0
- package/docs/rules/actionview-no-silent-render.md +47 -0
- package/docs/rules/erb-no-empty-control-flow.md +83 -0
- package/docs/rules/erb-no-silent-statement.md +53 -0
- package/docs/rules/erb-no-unsafe-script-interpolation.md +70 -3
- package/package.json +8 -8
- package/src/index.ts +21 -0
- package/src/rules/actionview-no-silent-render.ts +44 -0
- package/src/rules/erb-no-case-node-children.ts +3 -1
- package/src/rules/erb-no-duplicate-branch-elements.ts +130 -14
- package/src/rules/erb-no-empty-control-flow.ts +255 -0
- package/src/rules/erb-no-silent-statement.ts +58 -0
- package/src/rules/erb-no-unsafe-script-interpolation.ts +51 -5
- package/src/rules/html-allowed-script-type.ts +1 -1
- package/src/rules/index.ts +21 -16
- package/src/rules/rule-utils.ts +15 -24
- package/src/rules.ts +8 -2
- 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":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/docs/rules/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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); "`,
|
|
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.
|
|
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.
|
|
50
|
-
"@herb-tools/core": "0.9.
|
|
51
|
-
"@herb-tools/highlighter": "0.9.
|
|
52
|
-
"@herb-tools/node-wasm": "0.9.
|
|
53
|
-
"@herb-tools/printer": "0.9.
|
|
54
|
-
"@herb-tools/rewriter": "0.9.
|
|
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.
|
|
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
|
}
|