@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
@@ -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
  }
@@ -0,0 +1,255 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+ import { isHTMLTextNode, isERBIfNode, isERBElseNode, Location } from "@herb-tools/core"
4
+
5
+ import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6
+ import type {
7
+ Node,
8
+ ERBIfNode,
9
+ ERBElseNode,
10
+ ERBUnlessNode,
11
+ ERBForNode,
12
+ ERBWhileNode,
13
+ ERBUntilNode,
14
+ ERBWhenNode,
15
+ ERBBeginNode,
16
+ ERBRescueNode,
17
+ ERBEnsureNode,
18
+ ERBBlockNode,
19
+ ERBInNode,
20
+ ParseResult,
21
+ } from "@herb-tools/core"
22
+
23
+ class ERBNoEmptyControlFlowVisitor extends BaseRuleVisitor {
24
+ private processedIfNodes: Set<ERBIfNode> = new Set()
25
+ private processedElseNodes: Set<ERBElseNode> = new Set()
26
+
27
+ visitERBIfNode(node: ERBIfNode): void {
28
+ if (this.processedIfNodes.has(node)) {
29
+ return
30
+ }
31
+
32
+ this.markIfChainAsProcessed(node)
33
+ this.markElseNodesInIfChain(node)
34
+
35
+ const entireChainEmpty = this.isEntireIfChainEmpty(node)
36
+
37
+ if (entireChainEmpty) {
38
+ this.addEmptyBlockOffense(node, node.statements, "if")
39
+ } else {
40
+ this.checkIfChainParts(node)
41
+ }
42
+
43
+ this.visitChildNodes(node)
44
+ }
45
+
46
+ visitERBElseNode(node: ERBElseNode): void {
47
+ if (this.processedElseNodes.has(node)) {
48
+ this.visitChildNodes(node)
49
+ return
50
+ }
51
+
52
+ this.addEmptyBlockOffense(node, node.statements, "else")
53
+ this.visitChildNodes(node)
54
+ }
55
+
56
+ visitERBUnlessNode(node: ERBUnlessNode): void {
57
+ const unlessHasContent = this.statementsHaveContent(node.statements)
58
+ const elseHasContent = node.else_clause && this.statementsHaveContent(node.else_clause.statements)
59
+
60
+ if (node.else_clause) {
61
+ this.processedElseNodes.add(node.else_clause)
62
+ }
63
+
64
+ const entireBlockEmpty = !unlessHasContent && !elseHasContent
65
+
66
+ if (entireBlockEmpty) {
67
+ this.addEmptyBlockOffense(node, node.statements, "unless")
68
+ } else {
69
+ if (!unlessHasContent) {
70
+ this.addEmptyBlockOffenseWithEnd(node, node.statements, "unless", node.else_clause)
71
+ }
72
+
73
+ if (node.else_clause && !elseHasContent) {
74
+ this.addEmptyBlockOffense(node.else_clause, node.else_clause.statements, "else")
75
+ }
76
+ }
77
+
78
+ this.visitChildNodes(node)
79
+ }
80
+
81
+ visitERBForNode(node: ERBForNode): void {
82
+ this.addEmptyBlockOffense(node, node.statements, "for")
83
+ this.visitChildNodes(node)
84
+ }
85
+
86
+ visitERBWhileNode(node: ERBWhileNode): void {
87
+ this.addEmptyBlockOffense(node, node.statements, "while")
88
+ this.visitChildNodes(node)
89
+ }
90
+
91
+ visitERBUntilNode(node: ERBUntilNode): void {
92
+ this.addEmptyBlockOffense(node, node.statements, "until")
93
+ this.visitChildNodes(node)
94
+ }
95
+
96
+ visitERBWhenNode(node: ERBWhenNode): void {
97
+ if (!node.then_keyword) {
98
+ this.addEmptyBlockOffense(node, node.statements, "when")
99
+ }
100
+
101
+ this.visitChildNodes(node)
102
+ }
103
+
104
+ visitERBInNode(node: ERBInNode): void {
105
+ if (!node.then_keyword) {
106
+ this.addEmptyBlockOffense(node, node.statements, "in")
107
+ }
108
+
109
+ this.visitChildNodes(node)
110
+ }
111
+
112
+ visitERBBeginNode(node: ERBBeginNode): void {
113
+ this.addEmptyBlockOffense(node, node.statements, "begin")
114
+ this.visitChildNodes(node)
115
+ }
116
+
117
+ visitERBRescueNode(node: ERBRescueNode): void {
118
+ this.addEmptyBlockOffense(node, node.statements, "rescue")
119
+ this.visitChildNodes(node)
120
+ }
121
+
122
+ visitERBEnsureNode(node: ERBEnsureNode): void {
123
+ this.addEmptyBlockOffense(node, node.statements, "ensure")
124
+ this.visitChildNodes(node)
125
+ }
126
+
127
+ visitERBBlockNode(node: ERBBlockNode): void {
128
+ this.addEmptyBlockOffense(node, node.body, "do")
129
+ this.visitChildNodes(node)
130
+ }
131
+
132
+ private addEmptyBlockOffense(node: Node, statements: Node[], blockType: string): void {
133
+ this.addEmptyBlockOffenseWithEnd(node, statements, blockType, null)
134
+ }
135
+
136
+ private addEmptyBlockOffenseWithEnd(node: Node, statements: Node[], blockType: string, subsequentNode: Node | null): void {
137
+ if (this.statementsHaveContent(statements)) {
138
+ return
139
+ }
140
+
141
+ const startLocation = node.location.start
142
+ const endLocation = subsequentNode
143
+ ? subsequentNode.location.start
144
+ : node.location.end
145
+
146
+ const location = Location.from(
147
+ startLocation.line, startLocation.column,
148
+ endLocation.line, endLocation.column,
149
+ )
150
+
151
+ const offense = this.createOffense(
152
+ `Empty ${blockType} block: this control flow statement has no content`,
153
+ location,
154
+ )
155
+ offense.tags = ["unnecessary"]
156
+ this.offenses.push(offense)
157
+ }
158
+
159
+ private statementsHaveContent(statements: Node[]): boolean {
160
+ return statements.some(statement => {
161
+ if (isHTMLTextNode(statement)) {
162
+ return statement.content.trim() !== ""
163
+ }
164
+
165
+ return true
166
+ })
167
+ }
168
+
169
+ private markIfChainAsProcessed(node: ERBIfNode): void {
170
+ this.processedIfNodes.add(node)
171
+ this.traverseSubsequentNodes(node.subsequent, (current) => {
172
+ if (isERBIfNode(current)) {
173
+ this.processedIfNodes.add(current)
174
+ }
175
+ })
176
+ }
177
+
178
+ private markElseNodesInIfChain(node: ERBIfNode): void {
179
+ this.traverseSubsequentNodes(node.subsequent, (current) => {
180
+ if (isERBElseNode(current)) {
181
+ this.processedElseNodes.add(current)
182
+ }
183
+ })
184
+ }
185
+
186
+ private traverseSubsequentNodes(startNode: Node | null, callback: (node: ERBIfNode | ERBElseNode) => void): void {
187
+ let current: Node | null = startNode
188
+ while (current) {
189
+ if (isERBIfNode(current)) {
190
+ callback(current)
191
+ current = current.subsequent
192
+ } else if (isERBElseNode(current)) {
193
+ callback(current)
194
+ break
195
+ } else {
196
+ break
197
+ }
198
+ }
199
+ }
200
+
201
+ private checkIfChainParts(node: ERBIfNode): void {
202
+ if (!this.statementsHaveContent(node.statements)) {
203
+ this.addEmptyBlockOffenseWithEnd(node, node.statements, "if", node.subsequent)
204
+ }
205
+
206
+ this.traverseSubsequentNodes(node.subsequent, (current) => {
207
+ if (this.statementsHaveContent(current.statements)) {
208
+ return
209
+ }
210
+
211
+ const blockType = isERBIfNode(current) ? "elsif" : "else"
212
+ const nextSubsequent = isERBIfNode(current) ? current.subsequent : null
213
+
214
+ if (nextSubsequent) {
215
+ this.addEmptyBlockOffenseWithEnd(current, current.statements, blockType, nextSubsequent)
216
+ } else {
217
+ this.addEmptyBlockOffense(current, current.statements, blockType)
218
+ }
219
+ })
220
+ }
221
+
222
+ private isEntireIfChainEmpty(node: ERBIfNode): boolean {
223
+ if (this.statementsHaveContent(node.statements)) {
224
+ return false
225
+ }
226
+
227
+ let hasContent = false
228
+ this.traverseSubsequentNodes(node.subsequent, (current) => {
229
+ if (this.statementsHaveContent(current.statements)) {
230
+ hasContent = true
231
+ }
232
+ })
233
+
234
+ return !hasContent
235
+ }
236
+ }
237
+
238
+ export class ERBNoEmptyControlFlowRule extends ParserRule {
239
+ static ruleName = "erb-no-empty-control-flow"
240
+
241
+ get defaultConfig(): FullRuleConfig {
242
+ return {
243
+ enabled: true,
244
+ severity: "hint"
245
+ }
246
+ }
247
+
248
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
249
+ const visitor = new ERBNoEmptyControlFlowVisitor(this.ruleName, context)
250
+
251
+ visitor.visit(result.value)
252
+
253
+ return visitor.offenses
254
+ }
255
+ }
@@ -0,0 +1,58 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+
4
+ import { isERBOutputNode, PrismNode } from "@herb-tools/core"
5
+
6
+ import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
7
+ import type { ParseResult, ERBContentNode, ParserOptions } from "@herb-tools/core"
8
+
9
+ function isAssignmentNode(prismNode: PrismNode): boolean {
10
+ const type: string = prismNode?.constructor?.name
11
+ if (!type) return false
12
+
13
+ return type.endsWith("WriteNode")
14
+ }
15
+
16
+ class ERBNoSilentStatementVisitor extends BaseRuleVisitor {
17
+ visitERBContentNode(node: ERBContentNode): void {
18
+ if (isERBOutputNode(node)) return
19
+
20
+ const prismNode = node.prismNode
21
+ if (!prismNode) return
22
+
23
+ if (isAssignmentNode(prismNode)) return
24
+
25
+ const content = node.content?.value?.trim()
26
+ if (!content) return
27
+
28
+ this.addOffense(
29
+ `Avoid using silent ERB tags for statements. Move \`${content}\` to a controller, helper, or presenter.`,
30
+ node.location,
31
+ )
32
+ }
33
+ }
34
+
35
+ export class ERBNoSilentStatementRule extends ParserRule {
36
+ static ruleName = "erb-no-silent-statement"
37
+
38
+ get defaultConfig(): FullRuleConfig {
39
+ return {
40
+ enabled: false,
41
+ severity: "warning"
42
+ }
43
+ }
44
+
45
+ get parserOptions(): Partial<ParserOptions> {
46
+ return {
47
+ prism_nodes: true,
48
+ }
49
+ }
50
+
51
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
52
+ const visitor = new ERBNoSilentStatementVisitor(this.ruleName, context)
53
+
54
+ visitor.visit(result.value)
55
+
56
+ return visitor.offenses
57
+ }
58
+ }
@@ -1,5 +1,7 @@
1
1
  import { ParserRule } from "../types.js"
2
2
  import { BaseRuleVisitor } from "./rule-utils.js"
3
+ import { PrismVisitor } from "@herb-tools/core"
4
+
3
5
  import {
4
6
  getTagLocalName,
5
7
  getAttribute,
@@ -10,9 +12,34 @@ import {
10
12
  } from "@herb-tools/core"
11
13
 
12
14
  import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
13
- import type { ParseResult, HTMLElementNode, Node } from "@herb-tools/core"
15
+ import type { ParseResult, HTMLElementNode, Node, ERBContentNode, ParserOptions, PrismNode } from "@herb-tools/core"
16
+
17
+ const SAFE_METHOD_NAMES = new Set([
18
+ "to_json",
19
+ "json_escape",
20
+ ])
21
+
22
+ const ESCAPE_JAVASCRIPT_METHOD_NAMES = new Set([
23
+ "j",
24
+ "escape_javascript",
25
+ ])
26
+
27
+ class SafeCallDetector extends PrismVisitor {
28
+ public hasSafeCall = false
29
+ public hasEscapeJavascriptCall = false
30
+
31
+ visitCallNode(node: PrismNode): void {
32
+ if (SAFE_METHOD_NAMES.has(node.name)) {
33
+ this.hasSafeCall = true
34
+ }
35
+
36
+ if (ESCAPE_JAVASCRIPT_METHOD_NAMES.has(node.name)) {
37
+ this.hasEscapeJavascriptCall = true
38
+ }
14
39
 
15
- const SAFE_PATTERN = /\.to_json\b/
40
+ this.visitChildNodes(node)
41
+ }
42
+ }
16
43
 
17
44
  class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
18
45
  visitHTMLElementNode(node: HTMLElementNode): void {
@@ -35,7 +62,6 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
35
62
  const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null
36
63
 
37
64
  if (typeValue === "text/html") return
38
-
39
65
  if (!node.body || node.body.length === 0) return
40
66
 
41
67
  this.checkNodesForUnsafeOutput(node.body)
@@ -46,9 +72,21 @@ class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
46
72
  if (!isERBNode(child)) continue
47
73
  if (!isERBOutputNode(child)) continue
48
74
 
49
- const content = child.content?.value?.trim() || ""
75
+ const erbContent = child as ERBContentNode
76
+ const prismNode = erbContent.prismNode
77
+ const detector = new SafeCallDetector()
78
+
79
+ if (prismNode) detector.visit(prismNode)
80
+ if (detector.hasSafeCall) continue
81
+
82
+ if (detector.hasEscapeJavascriptCall) {
83
+ this.addOffense(
84
+ "Avoid `j()` / `escape_javascript()` in `<script>` tags. It is only safe inside quoted string literals. Use `.to_json` instead, which is safe in any position.",
85
+ child.location,
86
+ )
50
87
 
51
- if (SAFE_PATTERN.test(content)) continue
88
+ continue
89
+ }
52
90
 
53
91
  this.addOffense(
54
92
  "Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript.",
@@ -68,9 +106,17 @@ export class ERBNoUnsafeScriptInterpolationRule extends ParserRule {
68
106
  }
69
107
  }
70
108
 
109
+ get parserOptions(): Partial<ParserOptions> {
110
+ return {
111
+ prism_nodes: true,
112
+ }
113
+ }
114
+
71
115
  check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
72
116
  const visitor = new ERBNoUnsafeScriptInterpolationVisitor(this.ruleName, context)
117
+
73
118
  visitor.visit(result.value)
119
+
74
120
  return visitor.offenses
75
121
  }
76
122
  }
@@ -5,10 +5,10 @@ import { getTagLocalName, getAttribute, getStaticAttributeValue, hasAttributeVal
5
5
  import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6
6
  import type { HTMLAttributeNode, HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
7
7
 
8
- const ALLOWED_TYPES = ["text/javascript"]
9
8
  // NOTE: Rules are not configurable for now, keep some sane defaults
10
9
  // See https://github.com/marcoroth/herb/issues/1204
11
10
  const ALLOW_BLANK = true
11
+ const ALLOWED_TYPES = ["text/javascript", "module", "importmap", "speculationrules"]
12
12
 
13
13
  class AllowedScriptTypeVisitor extends BaseRuleVisitor {
14
14
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {