@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.
- package/README.md +54 -2
- package/dist/herb-lint.js +17157 -31275
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +473 -2113
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +468 -2115
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +6868 -11350
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +6862 -11351
- package/dist/loader.js.map +1 -1
- package/dist/package.json +9 -8
- package/dist/src/cli/argument-parser.js +18 -2
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +1 -1
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli.js +25 -10
- package/dist/src/cli.js.map +1 -1
- package/dist/src/custom-rule-loader.js +2 -2
- package/dist/src/custom-rule-loader.js.map +1 -1
- package/dist/src/linter.js +16 -3
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/src/rules/erb-strict-locals-required.js +38 -0
- package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/src/rules/file-utils.js +21 -0
- package/dist/src/rules/file-utils.js.map +1 -0
- package/dist/src/rules/html-head-only-elements.js +2 -0
- package/dist/src/rules/html-head-only-elements.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-attributes.js +91 -21
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +22 -36
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/index.js +4 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/string-utils.js +72 -0
- package/dist/src/rules/string-utils.js.map +1 -0
- package/dist/src/rules.js +4 -0
- package/dist/src/rules.js.map +1 -1
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +3 -0
- package/dist/types/cli/file-processor.d.ts +1 -0
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/linter.d.ts +5 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/rules/file-utils.d.ts +13 -0
- package/dist/types/rules/index.d.ts +4 -0
- package/dist/types/rules/string-utils.d.ts +15 -0
- package/dist/types/src/cli/argument-parser.d.ts +3 -0
- package/dist/types/src/cli/file-processor.d.ts +1 -0
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/linter.d.ts +5 -1
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
- package/dist/types/src/rules/file-utils.d.ts +13 -0
- package/dist/types/src/rules/index.d.ts +4 -0
- package/dist/types/src/rules/string-utils.d.ts +15 -0
- package/dist/types/src/types.d.ts +6 -0
- package/dist/types/types.d.ts +6 -0
- package/docs/rules/README.md +1 -0
- package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
- package/docs/rules/erb-strict-locals-required.md +107 -0
- package/package.json +9 -8
- package/src/cli/argument-parser.ts +21 -2
- package/src/cli/file-processor.ts +2 -1
- package/src/cli.ts +34 -11
- package/src/custom-rule-loader.ts +2 -2
- package/src/linter.ts +19 -3
- package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
- package/src/rules/erb-strict-locals-required.ts +52 -0
- package/src/rules/file-utils.ts +23 -0
- package/src/rules/html-head-only-elements.ts +1 -0
- package/src/rules/html-no-duplicate-attributes.ts +141 -26
- package/src/rules/html-no-empty-headings.ts +21 -44
- package/src/rules/index.ts +4 -0
- package/src/rules/string-utils.ts +72 -0
- package/src/rules.ts +4 -0
- package/src/types.ts +6 -0
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { glob } from "
|
|
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 {
|
|
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,
|
|
69
|
+
protected adjustPattern(pattern: string | undefined, configGlobPatterns: string[]): string {
|
|
70
70
|
if (!pattern) {
|
|
71
|
-
return
|
|
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
|
-
|
|
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
|
|
100
|
-
?
|
|
101
|
-
: '**/*.html.erb'
|
|
102
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|