@herb-tools/linter 0.8.7 → 0.8.8

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 (79) hide show
  1. package/README.md +28 -2
  2. package/dist/herb-lint.js +5406 -15659
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +381 -37
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +376 -39
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +1231 -7911
  9. package/dist/loader.cjs.map +1 -1
  10. package/dist/loader.js +1225 -7912
  11. package/dist/loader.js.map +1 -1
  12. package/dist/package.json +7 -7
  13. package/dist/src/cli/argument-parser.js +5 -2
  14. package/dist/src/cli/argument-parser.js.map +1 -1
  15. package/dist/src/cli/file-processor.js +1 -1
  16. package/dist/src/cli/file-processor.js.map +1 -1
  17. package/dist/src/cli.js +14 -8
  18. package/dist/src/cli.js.map +1 -1
  19. package/dist/src/custom-rule-loader.js +2 -2
  20. package/dist/src/custom-rule-loader.js.map +1 -1
  21. package/dist/src/linter.js +14 -1
  22. package/dist/src/linter.js.map +1 -1
  23. package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
  24. package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
  25. package/dist/src/rules/erb-strict-locals-required.js +38 -0
  26. package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
  27. package/dist/src/rules/file-utils.js +21 -0
  28. package/dist/src/rules/file-utils.js.map +1 -0
  29. package/dist/src/rules/html-head-only-elements.js +2 -0
  30. package/dist/src/rules/html-head-only-elements.js.map +1 -1
  31. package/dist/src/rules/html-no-empty-headings.js +22 -36
  32. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  33. package/dist/src/rules/index.js +4 -0
  34. package/dist/src/rules/index.js.map +1 -1
  35. package/dist/src/rules/string-utils.js +72 -0
  36. package/dist/src/rules/string-utils.js.map +1 -0
  37. package/dist/src/rules.js +4 -0
  38. package/dist/src/rules.js.map +1 -1
  39. package/dist/src/types.js +6 -0
  40. package/dist/src/types.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/dist/types/cli/argument-parser.d.ts +1 -0
  43. package/dist/types/cli/file-processor.d.ts +1 -0
  44. package/dist/types/cli.d.ts +1 -1
  45. package/dist/types/linter.d.ts +5 -1
  46. package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  47. package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
  48. package/dist/types/rules/file-utils.d.ts +13 -0
  49. package/dist/types/rules/index.d.ts +4 -0
  50. package/dist/types/rules/string-utils.d.ts +15 -0
  51. package/dist/types/src/cli/argument-parser.d.ts +1 -0
  52. package/dist/types/src/cli/file-processor.d.ts +1 -0
  53. package/dist/types/src/cli.d.ts +1 -1
  54. package/dist/types/src/linter.d.ts +5 -1
  55. package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  56. package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
  57. package/dist/types/src/rules/file-utils.d.ts +13 -0
  58. package/dist/types/src/rules/index.d.ts +4 -0
  59. package/dist/types/src/rules/string-utils.d.ts +15 -0
  60. package/dist/types/src/types.d.ts +6 -0
  61. package/dist/types/types.d.ts +6 -0
  62. package/docs/rules/README.md +1 -0
  63. package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
  64. package/docs/rules/erb-strict-locals-required.md +107 -0
  65. package/package.json +7 -7
  66. package/src/cli/argument-parser.ts +6 -2
  67. package/src/cli/file-processor.ts +2 -1
  68. package/src/cli.ts +18 -8
  69. package/src/custom-rule-loader.ts +2 -2
  70. package/src/linter.ts +17 -1
  71. package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
  72. package/src/rules/erb-strict-locals-required.ts +52 -0
  73. package/src/rules/file-utils.ts +23 -0
  74. package/src/rules/html-head-only-elements.ts +1 -0
  75. package/src/rules/html-no-empty-headings.ts +21 -44
  76. package/src/rules/index.ts +4 -0
  77. package/src/rules/string-utils.ts +72 -0
  78. package/src/rules.ts +4 -0
  79. package/src/types.ts +6 -0
@@ -0,0 +1,274 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule } from "../types.js"
3
+
4
+ import { isPartialFile } from "./file-utils.js"
5
+ import { hasBalancedParentheses, splitByTopLevelComma } from "./string-utils.js"
6
+
7
+ import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
8
+ import type { ParseResult, ERBContentNode } from "@herb-tools/core"
9
+
10
+ export const STRICT_LOCALS_PATTERN = /^locals:\s*\([^)]*\)\s*$/
11
+
12
+ function isValidStrictLocalsFormat(content: string): boolean {
13
+ return STRICT_LOCALS_PATTERN.test(content)
14
+ }
15
+
16
+ function extractERBCommentContent(content: string): string {
17
+ return content.trim()
18
+ }
19
+
20
+ function extractRubyCommentContent(content: string): string | null {
21
+ const match = content.match(/^\s*#\s*(.*)$/)
22
+
23
+ return match ? match[1].trim() : null
24
+ }
25
+
26
+ function extractLocalsRemainder(content: string): string | null {
27
+ const match = content.match(/^locals?\b(.*)$/)
28
+
29
+ return match ? match[1] : null
30
+ }
31
+
32
+ function looksLikeLocalsDeclaration(content: string): boolean {
33
+ return /^locals?\b/.test(content) && /[(:)]/.test(content)
34
+ }
35
+
36
+ function hasLocalsLikeSyntax(remainder: string): boolean {
37
+ return /[(:)]/.test(remainder)
38
+ }
39
+
40
+ function detectLocalsWithoutColon(content: string): boolean {
41
+ return /^locals?\(/.test(content)
42
+ }
43
+
44
+ function detectSingularLocal(content: string): boolean {
45
+ return /^local:/.test(content)
46
+ }
47
+
48
+ function detectMissingColonBeforeParens(content: string): boolean {
49
+ return /^locals\s+\(/.test(content)
50
+ }
51
+
52
+ function detectMissingParentheses(content: string): boolean {
53
+ return /^locals:\s*[^(]/.test(content)
54
+ }
55
+
56
+ function detectEmptyLocalsWithoutParens(content: string): boolean {
57
+ return /^locals:\s*$/.test(content)
58
+ }
59
+
60
+ function validateCommaUsage(inner: string): string | null {
61
+ if (inner.startsWith(",") || inner.endsWith(",") || /,,/.test(inner)) {
62
+ return "Unexpected comma in `locals:` parameters."
63
+ }
64
+
65
+ return null
66
+ }
67
+
68
+ function validateBlockArgument(param: string): string | null {
69
+ if (param.startsWith("&")) {
70
+ return `Block argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
71
+ }
72
+
73
+ return null
74
+ }
75
+
76
+ function validateSplatArgument(param: string): string | null {
77
+ if (param.startsWith("*") && !param.startsWith("**")) {
78
+ return `Splat argument \`${param}\` is not allowed. Strict locals only support keyword arguments.`
79
+ }
80
+
81
+ return null
82
+ }
83
+
84
+ function validateDoubleSplatArgument(param: string): string | null {
85
+ if (param.startsWith("**")) {
86
+ if (/^\*\*\w+$/.test(param)) {
87
+ return null // Valid double-splat
88
+ }
89
+
90
+ return `Invalid double-splat syntax \`${param}\`. Use \`**name\` format (e.g., \`**attributes\`).`
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ function validateKeywordArgument(param: string): string | null {
97
+ if (!/^\w+:\s*/.test(param)) {
98
+ if (/^\w+$/.test(param)) {
99
+ return `Positional argument \`${param}\` is not allowed. Use keyword argument format: \`${param}:\`.`
100
+ }
101
+
102
+ return `Invalid parameter \`${param}\`. Use keyword argument format: \`name:\` or \`name: default\`.`
103
+ }
104
+
105
+ return null
106
+ }
107
+
108
+ function validateParameter(param: string): string | null {
109
+ const trimmed = param.trim()
110
+
111
+ if (!trimmed) return null
112
+
113
+ return (
114
+ validateBlockArgument(trimmed) ||
115
+ validateSplatArgument(trimmed) ||
116
+ validateDoubleSplatArgument(trimmed) ||
117
+ (trimmed.startsWith("**") ? null : validateKeywordArgument(trimmed))
118
+ )
119
+ }
120
+
121
+ function validateLocalsSignature(paramsContent: string): string | null {
122
+ const match = paramsContent.match(/^\s*\(([\s\S]*)\)\s*$/)
123
+ if (!match) return null
124
+
125
+ const inner = match[1].trim()
126
+ if (!inner) return null // Empty locals is valid: locals: ()
127
+
128
+ const commaError = validateCommaUsage(inner)
129
+ if (commaError) return commaError
130
+
131
+ const params = splitByTopLevelComma(inner)
132
+
133
+ for (const param of params) {
134
+ const error = validateParameter(param)
135
+ if (error) return error
136
+ }
137
+
138
+ return null
139
+ }
140
+
141
+ class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
142
+ private seenStrictLocalsComment: boolean = false
143
+ private firstStrictLocalsLocation: { line: number; column: number } | null = null
144
+
145
+ visitERBContentNode(node: ERBContentNode): void {
146
+ const openingTag = node.tag_opening?.value
147
+ const content = node.content?.value
148
+
149
+ if (!content) return
150
+
151
+ const commentContent = this.extractCommentContent(openingTag, content, node)
152
+ if (!commentContent) return
153
+
154
+ const remainder = extractLocalsRemainder(commentContent)
155
+ if (!remainder || !hasLocalsLikeSyntax(remainder)) return
156
+
157
+ this.validateLocalsComment(commentContent, node)
158
+ }
159
+
160
+ private extractCommentContent(openingTag: string | undefined, content: string, node: ERBContentNode): string | null {
161
+ if (openingTag === "<%#") {
162
+ return extractERBCommentContent(content)
163
+ }
164
+
165
+ if (openingTag === "<%" || openingTag === "<%-") {
166
+ const rubyComment = extractRubyCommentContent(content)
167
+
168
+ if (rubyComment && looksLikeLocalsDeclaration(rubyComment)) {
169
+ this.addOffense(`Use \`<%#\` instead of \`${openingTag} #\` for strict locals comments. Only ERB comment syntax is recognized by Rails.`, node.location)
170
+ }
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ private validateLocalsComment(commentContent: string, node: ERBContentNode): void {
177
+ this.checkPartialFile(node)
178
+
179
+ if (!hasBalancedParentheses(commentContent)) {
180
+ this.addOffense("Unbalanced parentheses in `locals:` comment. Ensure all opening parentheses have matching closing parentheses.", node.location)
181
+ return
182
+ }
183
+
184
+ if (isValidStrictLocalsFormat(commentContent)) {
185
+ this.handleValidFormat(commentContent, node)
186
+ return
187
+ }
188
+
189
+ this.handleInvalidFormat(commentContent, node)
190
+ }
191
+
192
+ private checkPartialFile(node: ERBContentNode): void {
193
+ const isPartial = isPartialFile(this.context.fileName)
194
+
195
+ if (isPartial === false) {
196
+ this.addOffense("Strict locals (`locals:`) only work in partials (files starting with `_`). This declaration will be ignored.", node.location)
197
+ }
198
+ }
199
+
200
+ private handleValidFormat(commentContent: string, node: ERBContentNode): void {
201
+ if (this.seenStrictLocalsComment) {
202
+ this.addOffense(
203
+ `Duplicate \`locals:\` declaration. Only one \`locals:\` comment is allowed per partial (first declaration at line ${this.firstStrictLocalsLocation?.line}).`,
204
+ node.location
205
+ )
206
+
207
+ return
208
+ }
209
+
210
+ this.seenStrictLocalsComment = true
211
+ this.firstStrictLocalsLocation = {
212
+ line: node.location.start.line,
213
+ column: node.location.start.column
214
+ }
215
+
216
+ const paramsMatch = commentContent.match(/^locals:\s*(\([\s\S]*\))\s*$/)
217
+
218
+ if (paramsMatch) {
219
+ const error = validateLocalsSignature(paramsMatch[1])
220
+
221
+ if (error) {
222
+ this.addOffense(error, node.location)
223
+ }
224
+ }
225
+ }
226
+
227
+ private handleInvalidFormat(commentContent: string, node: ERBContentNode): void {
228
+ if (detectLocalsWithoutColon(commentContent)) {
229
+ this.addOffense("Use `locals:` with a colon, not `locals()`. Correct format: `<%# locals: (...) %>`.", node.location)
230
+ return
231
+ }
232
+
233
+ if (detectSingularLocal(commentContent)) {
234
+ this.addOffense("Use `locals:` (plural), not `local:`.", node.location)
235
+ return
236
+ }
237
+
238
+ if (detectMissingColonBeforeParens(commentContent)) {
239
+ this.addOffense("Use `locals:` with a colon before the parentheses, not `locals (`.", node.location)
240
+ return
241
+ }
242
+
243
+ if (detectMissingParentheses(commentContent)) {
244
+ this.addOffense("Wrap parameters in parentheses: `locals: (name:)` or `locals: (name: default)`.", node.location)
245
+ return
246
+ }
247
+
248
+ if (detectEmptyLocalsWithoutParens(commentContent)) {
249
+ this.addOffense("Add parameters after `locals:`. Use `locals: (name:)` or `locals: ()` for no locals.", node.location)
250
+ return
251
+ }
252
+
253
+ this.addOffense("Invalid `locals:` syntax. Use format: `locals: (name:, option: default)`.", node.location)
254
+ }
255
+ }
256
+
257
+ export class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
258
+ name = "erb-strict-locals-comment-syntax"
259
+
260
+ get defaultConfig(): FullRuleConfig {
261
+ return {
262
+ enabled: true,
263
+ severity: "error"
264
+ }
265
+ }
266
+
267
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
268
+ const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.name, context)
269
+
270
+ visitor.visit(result.value)
271
+
272
+ return visitor.offenses
273
+ }
274
+ }
@@ -0,0 +1,52 @@
1
+ import { SourceRule } from "../types.js"
2
+ import { Location } from "@herb-tools/core"
3
+ import { BaseSourceRuleVisitor } from "./rule-utils.js"
4
+
5
+ import { isPartialFile } from "./file-utils.js"
6
+
7
+ import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
8
+
9
+ function hasStrictLocals(source: string): boolean {
10
+ return source.includes("<%# locals:") || source.includes("<%#locals:")
11
+ }
12
+
13
+ class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
14
+ protected visitSource(source: string): void {
15
+ const isPartial = isPartialFile(this.context.fileName)
16
+
17
+ if (isPartial !== true) return
18
+ if (hasStrictLocals(source)) return
19
+
20
+ const firstLineLength = source.indexOf("\n") === -1 ? source.length : source.indexOf("\n")
21
+ const location = Location.from(1, 0, 1, firstLineLength)
22
+
23
+ this.addOffense(
24
+ "Partial is missing a strict locals declaration. Add `<%# locals: (...) %>` at the top of the file.",
25
+ location
26
+ )
27
+ }
28
+ }
29
+
30
+ export class ERBStrictLocalsRequiredRule extends SourceRule {
31
+ static unsafeAutocorrectable = true
32
+ name = "erb-strict-locals-required"
33
+
34
+ get defaultConfig(): FullRuleConfig {
35
+ return {
36
+ enabled: false,
37
+ severity: "error",
38
+ }
39
+ }
40
+
41
+ check(source: string, context?: Partial<LintContext>): UnboundLintOffense[] {
42
+ const visitor = new ERBStrictLocalsRequiredVisitor(this.name, context)
43
+
44
+ visitor.visit(source)
45
+
46
+ return visitor.offenses
47
+ }
48
+
49
+ autofix(_offense: LintOffense, source: string, _context?: Partial<LintContext>): string | null {
50
+ return `<%# locals: () %>\n\n${source}`
51
+ }
52
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * File path and naming utilities for linter rules
3
+ */
4
+
5
+ /**
6
+ * Extracts the basename (filename) from a file path
7
+ * Works with both forward slashes and backslashes
8
+ */
9
+ export function getBasename(filePath: string): string {
10
+ const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"))
11
+
12
+ return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1)
13
+ }
14
+
15
+ /**
16
+ * Checks if a file is a Rails partial (filename starts with `_`)
17
+ * Returns null if fileName is undefined (unknown context)
18
+ */
19
+ export function isPartialFile(fileName: string | undefined): boolean | null {
20
+ if (!fileName) return null
21
+
22
+ return getBasename(fileName).startsWith("_")
23
+ }
@@ -23,6 +23,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
23
23
  if (!this.insideBody) return
24
24
  if (!isHeadOnlyTag(tagName)) return
25
25
  if (tagName === "title" && this.insideSVG) return
26
+ if (tagName === "style" && this.insideSVG) return
26
27
  if (tagName === "meta" && this.hasItempropAttribute(node)) return
27
28
 
28
29
  this.addOffense(
@@ -1,31 +1,29 @@
1
1
  import { BaseRuleVisitor, getTagName, getAttributes, findAttributeByName, getAttributeValue, HEADING_TAGS } from "./rule-utils.js"
2
+ import { isHTMLOpenTagNode, isLiteralNode, isHTMLTextNode, isHTMLElementNode } from "@herb-tools/core"
2
3
 
3
4
  import { ParserRule } from "../types.js"
4
5
 
5
6
  import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6
- import type { HTMLElementNode, HTMLOpenTagNode, ParseResult, LiteralNode, HTMLTextNode } from "@herb-tools/core"
7
+ import type { HTMLElementNode, HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
7
8
 
8
9
  class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
9
10
  visitHTMLElementNode(node: HTMLElementNode): void {
11
+ const tagName = getTagName(node.open_tag)?.toLowerCase()
12
+ if (tagName === "template") return
13
+
10
14
  this.checkHeadingElement(node)
11
15
  super.visitHTMLElementNode(node)
12
16
  }
13
17
 
14
-
15
18
  private checkHeadingElement(node: HTMLElementNode): void {
16
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
17
- return
18
- }
19
+ if (!node.open_tag) return
20
+ if (!isHTMLOpenTagNode(node.open_tag)) return
19
21
 
20
- const openTag = node.open_tag as HTMLOpenTagNode
21
- const tagName = getTagName(openTag)
22
-
23
- if (!tagName) {
24
- return
25
- }
22
+ const tagName = getTagName(node.open_tag)
23
+ if (!tagName) return
26
24
 
27
25
  const isStandardHeading = HEADING_TAGS.has(tagName)
28
- const isAriaHeading = this.hasHeadingRole(openTag)
26
+ const isAriaHeading = this.hasHeadingRole(node.open_tag)
29
27
 
30
28
  if (!isStandardHeading && !isAriaHeading) {
31
29
  return
@@ -43,7 +41,6 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
43
41
  }
44
42
  }
45
43
 
46
-
47
44
  private isEmptyHeading(node: HTMLElementNode): boolean {
48
45
  if (!node.body || node.body.length === 0) {
49
46
  return true
@@ -52,24 +49,13 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
52
49
  let hasAccessibleContent = false
53
50
 
54
51
  for (const child of node.body) {
55
- if (child.type === "AST_LITERAL_NODE") {
56
- const literalNode = child as LiteralNode
57
-
58
- if (literalNode.content.trim().length > 0) {
52
+ if (isLiteralNode(child) || isHTMLTextNode(child)) {
53
+ if (child.content.trim().length > 0) {
59
54
  hasAccessibleContent = true
60
55
  break
61
56
  }
62
- } else if (child.type === "AST_HTML_TEXT_NODE") {
63
- const textNode = child as HTMLTextNode
64
-
65
- if (textNode.content.trim().length > 0) {
66
- hasAccessibleContent = true
67
- break
68
- }
69
- } else if (child.type === "AST_HTML_ELEMENT_NODE") {
70
- const elementNode = child as HTMLElementNode
71
-
72
- if (this.isElementAccessible(elementNode)) {
57
+ } else if (isHTMLElementNode(child)) {
58
+ if (this.isElementAccessible(child)) {
73
59
  hasAccessibleContent = true
74
60
  break
75
61
  }
@@ -95,12 +81,10 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
95
81
  }
96
82
 
97
83
  private isElementAccessible(node: HTMLElementNode): boolean {
98
- if (!node.open_tag || node.open_tag.type !== "AST_HTML_OPEN_TAG_NODE") {
99
- return true
100
- }
84
+ if (!node.open_tag) return true
85
+ if (!isHTMLOpenTagNode(node.open_tag)) return true
101
86
 
102
- const openTag = node.open_tag as HTMLOpenTagNode
103
- const attributes = getAttributes(openTag)
87
+ const attributes = getAttributes(node.open_tag)
104
88
  const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden")
105
89
 
106
90
  if (ariaHiddenAttribute) {
@@ -116,19 +100,12 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
116
100
  }
117
101
 
118
102
  for (const child of node.body) {
119
- if (child.type === "AST_LITERAL_NODE") {
120
- const literalNode = child as LiteralNode
121
- if (literalNode.content.trim().length > 0) {
122
- return true
123
- }
124
- } else if (child.type === "AST_HTML_TEXT_NODE") {
125
- const textNode = child as HTMLTextNode
126
- if (textNode.content.trim().length > 0) {
103
+ if (isLiteralNode(child) || isHTMLTextNode(child)) {
104
+ if (child.content.trim().length > 0) {
127
105
  return true
128
106
  }
129
- } else if (child.type === "AST_HTML_ELEMENT_NODE") {
130
- const elementNode = child as HTMLElementNode
131
- if (this.isElementAccessible(elementNode)) {
107
+ } else if (isHTMLElementNode(child)) {
108
+ if (this.isElementAccessible(child)) {
132
109
  return true
133
110
  }
134
111
  } else {
@@ -1,4 +1,6 @@
1
1
  export * from "./rule-utils.js"
2
+ export * from "./file-utils.js"
3
+ export * from "./string-utils.js"
2
4
  export * from "./herb-disable-comment-base.js"
3
5
 
4
6
  export * from "./erb-comment-syntax.js"
@@ -12,6 +14,8 @@ export * from "./erb-prefer-image-tag-helper.js"
12
14
  export * from "./erb-require-trailing-newline.js"
13
15
  export * from "./erb-require-whitespace-inside-tags.js"
14
16
  export * from "./erb-right-trim.js"
17
+ export * from "./erb-strict-locals-comment-syntax.js"
18
+ export * from "./erb-strict-locals-required.js"
15
19
 
16
20
  export * from "./herb-disable-comment-valid-rule-name.js"
17
21
  export * from "./herb-disable-comment-no-redundant-all.js"
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Checks if parentheses in a string are balanced
3
+ * Returns false if there are more closing parens than opening at any point
4
+ */
5
+ export function hasBalancedParentheses(content: string): boolean {
6
+ let depth = 0
7
+
8
+ for (const char of content) {
9
+ if (char === "(") depth++
10
+ if (char === ")") depth--
11
+ if (depth < 0) return false
12
+ }
13
+
14
+ return depth === 0
15
+ }
16
+
17
+ /**
18
+ * Splits a string by commas at the top level only
19
+ * Respects nested parentheses, brackets, braces, and strings
20
+ *
21
+ * @example
22
+ * splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
23
+ * splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
24
+ * splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
25
+ */
26
+ export function splitByTopLevelComma(str: string): string[] {
27
+ const result: string[] = []
28
+
29
+ let current = ""
30
+ let parenDepth = 0
31
+ let bracketDepth = 0
32
+ let braceDepth = 0
33
+ let inString = false
34
+ let stringChar = ""
35
+
36
+ for (let i = 0; i < str.length; i++) {
37
+ const char = str[i]
38
+ const previousChar = i > 0 ? str[i - 1] : ""
39
+
40
+ if ((char === '"' || char === "'") && previousChar !== "\\") {
41
+ if (!inString) {
42
+ inString = true
43
+ stringChar = char
44
+ } else if (char === stringChar) {
45
+ inString = false
46
+ }
47
+ }
48
+
49
+ if (!inString) {
50
+ if (char === "(") parenDepth++
51
+ if (char === ")") parenDepth--
52
+ if (char === "[") bracketDepth++
53
+ if (char === "]") bracketDepth--
54
+ if (char === "{") braceDepth++
55
+ if (char === "}") braceDepth--
56
+
57
+ if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
58
+ result.push(current)
59
+ current = ""
60
+ continue
61
+ }
62
+ }
63
+
64
+ current += char
65
+ }
66
+
67
+ if (current) {
68
+ result.push(current)
69
+ }
70
+
71
+ return result
72
+ }
package/src/rules.ts CHANGED
@@ -11,6 +11,8 @@ import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper
11
11
  import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js"
12
12
  import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
13
13
  import { ERBRightTrimRule } from "./rules/erb-right-trim.js"
14
+ import { ERBStrictLocalsCommentSyntaxRule } from "./rules/erb-strict-locals-comment-syntax.js"
15
+ import { ERBStrictLocalsRequiredRule } from "./rules/erb-strict-locals-required.js"
14
16
 
15
17
  import { HerbDisableCommentValidRuleNameRule } from "./rules/herb-disable-comment-valid-rule-name.js"
16
18
  import { HerbDisableCommentNoRedundantAllRule } from "./rules/herb-disable-comment-no-redundant-all.js"
@@ -67,6 +69,8 @@ export const rules: RuleClass[] = [
67
69
  ERBRequireTrailingNewlineRule,
68
70
  ERBRequireWhitespaceRule,
69
71
  ERBRightTrimRule,
72
+ ERBStrictLocalsCommentSyntaxRule,
73
+ ERBStrictLocalsRequiredRule,
70
74
 
71
75
  HerbDisableCommentValidRuleNameRule,
72
76
  HerbDisableCommentNoRedundantAllRule,
package/src/types.ts CHANGED
@@ -85,6 +85,8 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
85
85
  static type = "parser" as const
86
86
  /** Indicates whether this rule supports autofix. Defaults to false. */
87
87
  static autocorrectable = false
88
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
89
+ static unsafeAutocorrectable = false
88
90
  abstract name: string
89
91
 
90
92
  get defaultConfig(): FullRuleConfig {
@@ -119,6 +121,8 @@ export abstract class LexerRule<TAutofixContext extends BaseAutofixContext = Bas
119
121
  static type = "lexer" as const
120
122
  /** Indicates whether this rule supports autofix. Defaults to false. */
121
123
  static autocorrectable = false
124
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
125
+ static unsafeAutocorrectable = false
122
126
  abstract name: string
123
127
 
124
128
  get defaultConfig(): FullRuleConfig {
@@ -176,6 +180,8 @@ export abstract class SourceRule<TAutofixContext extends BaseAutofixContext = Ba
176
180
  static type = "source" as const
177
181
  /** Indicates whether this rule supports autofix. Defaults to false. */
178
182
  static autocorrectable = false
183
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
184
+ static unsafeAutocorrectable = false
179
185
  abstract name: string
180
186
 
181
187
  get defaultConfig(): FullRuleConfig {