@herb-tools/linter 0.8.6 → 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 (82) hide show
  1. package/README.md +54 -2
  2. package/dist/herb-lint.js +17157 -31275
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +473 -2113
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +468 -2115
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +6868 -11350
  9. package/dist/loader.cjs.map +1 -1
  10. package/dist/loader.js +6862 -11351
  11. package/dist/loader.js.map +1 -1
  12. package/dist/package.json +9 -8
  13. package/dist/src/cli/argument-parser.js +18 -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 +25 -10
  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 +16 -3
  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-duplicate-attributes.js +91 -21
  32. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  33. package/dist/src/rules/html-no-empty-headings.js +22 -36
  34. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  35. package/dist/src/rules/index.js +4 -0
  36. package/dist/src/rules/index.js.map +1 -1
  37. package/dist/src/rules/string-utils.js +72 -0
  38. package/dist/src/rules/string-utils.js.map +1 -0
  39. package/dist/src/rules.js +4 -0
  40. package/dist/src/rules.js.map +1 -1
  41. package/dist/src/types.js +6 -0
  42. package/dist/src/types.js.map +1 -1
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/dist/types/cli/argument-parser.d.ts +3 -0
  45. package/dist/types/cli/file-processor.d.ts +1 -0
  46. package/dist/types/cli.d.ts +1 -1
  47. package/dist/types/linter.d.ts +5 -1
  48. package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  49. package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
  50. package/dist/types/rules/file-utils.d.ts +13 -0
  51. package/dist/types/rules/index.d.ts +4 -0
  52. package/dist/types/rules/string-utils.d.ts +15 -0
  53. package/dist/types/src/cli/argument-parser.d.ts +3 -0
  54. package/dist/types/src/cli/file-processor.d.ts +1 -0
  55. package/dist/types/src/cli.d.ts +1 -1
  56. package/dist/types/src/linter.d.ts +5 -1
  57. package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  58. package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
  59. package/dist/types/src/rules/file-utils.d.ts +13 -0
  60. package/dist/types/src/rules/index.d.ts +4 -0
  61. package/dist/types/src/rules/string-utils.d.ts +15 -0
  62. package/dist/types/src/types.d.ts +6 -0
  63. package/dist/types/types.d.ts +6 -0
  64. package/docs/rules/README.md +1 -0
  65. package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
  66. package/docs/rules/erb-strict-locals-required.md +107 -0
  67. package/package.json +9 -8
  68. package/src/cli/argument-parser.ts +21 -2
  69. package/src/cli/file-processor.ts +2 -1
  70. package/src/cli.ts +34 -11
  71. package/src/custom-rule-loader.ts +2 -2
  72. package/src/linter.ts +19 -3
  73. package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
  74. package/src/rules/erb-strict-locals-required.ts +52 -0
  75. package/src/rules/file-utils.ts +23 -0
  76. package/src/rules/html-head-only-elements.ts +1 -0
  77. package/src/rules/html-no-duplicate-attributes.ts +141 -26
  78. package/src/rules/html-no-empty-headings.ts +21 -44
  79. package/src/rules/index.ts +4 -0
  80. package/src/rules/string-utils.ts +72 -0
  81. package/src/rules.ts +4 -0
  82. package/src/types.ts +6 -0
package/src/cli.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { glob } from "glob"
1
+ import { glob } from "tinyglobby"
2
2
  import { Herb } from "@herb-tools/node-wasm"
3
3
  import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config"
4
4
 
5
5
  import { existsSync, statSync } from "fs"
6
- import { dirname, resolve, relative } from "path"
6
+ import { resolve, relative } from "path"
7
7
 
8
8
  import { ArgumentParser } from "./cli/argument-parser.js"
9
9
  import { FileProcessor } from "./cli/file-processor.js"
@@ -66,9 +66,9 @@ export class CLI {
66
66
  }
67
67
  }
68
68
 
69
- protected adjustPattern(pattern: string | undefined, configGlobPattern: string): string {
69
+ protected adjustPattern(pattern: string | undefined, configGlobPatterns: string[]): string {
70
70
  if (!pattern) {
71
- return configGlobPattern
71
+ return configGlobPatterns.length === 1 ? configGlobPatterns[0] : `{${configGlobPatterns.join(',')}}`
72
72
  }
73
73
 
74
74
  const resolvedPattern = resolve(pattern)
@@ -77,7 +77,15 @@ export class CLI {
77
77
  const stats = statSync(resolvedPattern)
78
78
 
79
79
  if (stats.isDirectory()) {
80
- return configGlobPattern
80
+ const relativeDir = relative(this.projectPath, resolvedPattern)
81
+
82
+ if (relativeDir) {
83
+ const scopedPatterns = configGlobPatterns.map(pattern => `${relativeDir}/${pattern}`)
84
+
85
+ return scopedPatterns.length === 1 ? scopedPatterns[0] : `{${scopedPatterns.join(',')}}`
86
+ }
87
+
88
+ return configGlobPatterns.length === 1 ? configGlobPatterns[0] : `{${configGlobPatterns.join(',')}}`
81
89
  } else if (stats.isFile()) {
82
90
  return relative(this.projectPath, resolvedPattern)
83
91
  }
@@ -96,10 +104,11 @@ export class CLI {
96
104
  }
97
105
 
98
106
  const filesConfig = config.getFilesConfigForTool('linter')
99
- const configGlobPattern = filesConfig.include && filesConfig.include.length > 0
100
- ? (filesConfig.include.length === 1 ? filesConfig.include[0] : `{${filesConfig.include.join(',')}}`)
101
- : '**/*.html.erb'
102
- const adjustedPattern = this.adjustPattern(pattern, configGlobPattern)
107
+ const configGlobPatterns = filesConfig.include && filesConfig.include.length > 0
108
+ ? filesConfig.include
109
+ : ['**/*.html.erb']
110
+
111
+ const adjustedPattern = this.adjustPattern(pattern, configGlobPatterns)
103
112
 
104
113
  let files = await glob(adjustedPattern, {
105
114
  cwd: this.projectPath,
@@ -135,7 +144,7 @@ export class CLI {
135
144
  const startTime = Date.now()
136
145
  const startDate = new Date()
137
146
 
138
- let { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init, loadCustomRules } = this.argumentParser.parse(process.argv)
147
+ let { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel } = this.argumentParser.parse(process.argv)
139
148
 
140
149
  this.determineProjectPath(patterns)
141
150
 
@@ -239,6 +248,7 @@ export class CLI {
239
248
  projectPath: this.projectPath,
240
249
  pattern: patterns.join(' '),
241
250
  fix,
251
+ fixUnsafe,
242
252
  ignoreDisableComments,
243
253
  linterConfig,
244
254
  config: processingConfig,
@@ -250,7 +260,20 @@ export class CLI {
250
260
  await this.outputManager.outputResults({ ...results, files }, outputOptions)
251
261
  await this.afterProcess(results, outputOptions)
252
262
 
253
- if (results.totalErrors > 0) {
263
+ const effectiveFailLevel = failLevel || linterConfig.failLevel
264
+
265
+ const errors = results.totalErrors > 0
266
+ const warnings = results.totalWarnings > 0
267
+ const info = results.totalInfo > 0
268
+ const hints = results.totalHints > 0
269
+
270
+ const shouldFailOnWarnings = effectiveFailLevel === "warning" && warnings
271
+ const shouldFailOnInfo = effectiveFailLevel === "info" && (warnings || info)
272
+ const shouldFailOnHints = effectiveFailLevel === "hint" && (warnings || info || hints)
273
+
274
+ const shouldFail = errors || shouldFailOnWarnings || shouldFailOnInfo || shouldFailOnHints
275
+
276
+ if (shouldFail) {
254
277
  process.exit(1)
255
278
  }
256
279
 
@@ -1,5 +1,5 @@
1
1
  import { pathToFileURL } from "url"
2
- import { glob } from "glob"
2
+ import { glob } from "tinyglobby"
3
3
 
4
4
  import type { RuleClass } from "./types.js"
5
5
 
@@ -52,7 +52,7 @@ export class CustomRuleLoader {
52
52
  const files = await glob(pattern, {
53
53
  cwd: this.baseDir,
54
54
  absolute: true,
55
- nodir: true
55
+ onlyFiles: true
56
56
  })
57
57
 
58
58
  allFiles.push(...files)
package/src/linter.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Location } from "@herb-tools/core"
2
2
  import { IdentityPrinter } from "@herb-tools/printer"
3
- import { minimatch } from "minimatch"
3
+ import picomatch from "picomatch"
4
4
 
5
5
  import { rules } from "./rules.js"
6
6
  import { findNodeByLocation } from "./rules/rule-utils.js"
@@ -191,7 +191,7 @@ export class Linter {
191
191
  const defaultExclude = rule.defaultConfig?.exclude ?? DEFAULT_RULE_CONFIG.exclude
192
192
 
193
193
  if (defaultExclude && defaultExclude.length > 0) {
194
- const isExcluded = defaultExclude.some((pattern: string) => minimatch(context.fileName!, pattern))
194
+ const isExcluded = defaultExclude.some(pattern => picomatch.isMatch(context.fileName!, pattern))
195
195
 
196
196
  if (isExcluded) {
197
197
  return []
@@ -459,9 +459,12 @@ export class Linter {
459
459
  * @param source - The source code to fix
460
460
  * @param context - Optional context for linting (e.g., fileName)
461
461
  * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
462
+ * @param options - Options for autofix behavior
463
+ * @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
462
464
  * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
463
465
  */
464
- autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[]): AutofixResult {
466
+ autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: { includeUnsafe?: boolean }): AutofixResult {
467
+ const includeUnsafe = options?.includeUnsafe ?? false
465
468
  const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context)
466
469
 
467
470
  const parserOffenses: LintOffense[] = []
@@ -503,6 +506,7 @@ export class Linter {
503
506
  }
504
507
 
505
508
  const rule = new RuleClass() as ParserRule
509
+ const isUnsafe = (RuleClass as any).unsafeAutocorrectable === true
506
510
 
507
511
  if (!rule.autofix) {
508
512
  unfixed.push(offense)
@@ -510,6 +514,12 @@ export class Linter {
510
514
  continue
511
515
  }
512
516
 
517
+ if (isUnsafe && !includeUnsafe) {
518
+ unfixed.push(offense)
519
+
520
+ continue
521
+ }
522
+
513
523
  if (offense.autofixContext) {
514
524
  const originalNodeType = offense.autofixContext.node.type
515
525
  const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
@@ -562,12 +572,18 @@ export class Linter {
562
572
  }
563
573
 
564
574
  const rule = new RuleClass() as SourceRule
575
+ const isUnsafe = (RuleClass as any).unsafeAutocorrectable === true
565
576
 
566
577
  if (!rule.autofix) {
567
578
  unfixed.push(offense)
568
579
  continue
569
580
  }
570
581
 
582
+ if (isUnsafe && !includeUnsafe) {
583
+ unfixed.push(offense)
584
+ continue
585
+ }
586
+
571
587
  const correctedSource = rule.autofix(offense, currentSource, context)
572
588
 
573
589
  if (correctedSource) {
@@ -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(