@herb-tools/linter 0.4.2 → 0.5.0
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 +221 -10
- package/dist/herb-lint.js +817 -292
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +360 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +355 -83
- package/dist/index.js.map +1 -1
- package/dist/package.json +4 -4
- package/dist/src/cli/argument-parser.js +28 -18
- package/dist/src/cli/argument-parser.js.map +1 -1
- package/dist/src/cli/file-processor.js +21 -17
- package/dist/src/cli/file-processor.js.map +1 -1
- package/dist/src/cli/formatters/detailed-formatter.js +9 -9
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
- package/dist/src/cli/formatters/github-actions-formatter.js +50 -0
- package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +2 -0
- package/dist/src/cli/formatters/index.js.map +1 -1
- package/dist/src/cli/formatters/json-formatter.js +58 -0
- package/dist/src/cli/formatters/json-formatter.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +15 -15
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
- package/dist/src/cli/output-manager.js +120 -0
- package/dist/src/cli/output-manager.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +22 -22
- package/dist/src/cli/summary-reporter.js.map +1 -1
- package/dist/src/cli.js +41 -26
- package/dist/src/cli.js.map +1 -1
- package/dist/src/default-rules.js +8 -0
- package/dist/src/default-rules.js.map +1 -1
- package/dist/src/linter.js +37 -6
- package/dist/src/linter.js.map +1 -1
- package/dist/src/rules/erb-no-empty-tags.js +5 -4
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
- package/dist/src/rules/erb-no-output-control-flow.js +5 -4
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js +93 -0
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +5 -4
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
- package/dist/src/rules/erb-requires-trailing-newline.js +22 -0
- package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -0
- package/dist/src/rules/html-anchor-require-href.js +5 -4
- package/dist/src/rules/html-anchor-require-href.js.map +1 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js +5 -4
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-aria-level-must-be-valid.js +27 -0
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js +5 -4
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
- package/dist/src/rules/html-aria-role-must-be-valid.js +5 -4
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
- package/dist/src/rules/html-attribute-double-quotes.js +5 -4
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
- package/dist/src/rules/html-attribute-values-require-quotes.js +5 -4
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js +5 -4
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
- package/dist/src/rules/html-img-require-alt.js +5 -4
- package/dist/src/rules/html-img-require-alt.js.map +1 -1
- package/dist/src/rules/html-no-block-inside-inline.js +7 -6
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-attributes.js +5 -4
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +5 -4
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-empty-headings.js +5 -4
- package/dist/src/rules/html-no-empty-headings.js.map +1 -1
- package/dist/src/rules/html-no-nested-links.js +5 -4
- package/dist/src/rules/html-no-nested-links.js.map +1 -1
- package/dist/src/rules/html-tag-name-lowercase.js +5 -4
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
- package/dist/src/rules/index.js +3 -0
- package/dist/src/rules/index.js.map +1 -1
- package/dist/src/rules/parser-no-errors.js +18 -0
- package/dist/src/rules/parser-no-errors.js.map +1 -0
- package/dist/src/rules/rule-utils.js +125 -2
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/src/rules/svg-tag-name-capitalization.js +5 -4
- package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
- package/dist/src/types.js +15 -1
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/cli/argument-parser.d.ts +2 -1
- package/dist/types/cli/file-processor.d.ts +6 -5
- package/dist/types/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/cli/formatters/index.d.ts +2 -0
- package/dist/types/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/cli/output-manager.d.ts +31 -0
- package/dist/types/cli/summary-reporter.d.ts +3 -3
- package/dist/types/cli.d.ts +3 -1
- package/dist/types/linter.d.ts +20 -5
- package/dist/types/rules/erb-no-empty-tags.d.ts +5 -4
- package/dist/types/rules/erb-no-output-control-flow.d.ts +5 -4
- package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +7 -0
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +5 -4
- package/dist/types/rules/erb-requires-trailing-newline.d.ts +6 -0
- package/dist/types/rules/html-anchor-require-href.d.ts +5 -4
- package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +5 -4
- package/dist/types/rules/html-aria-level-must-be-valid.d.ts +7 -0
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +5 -4
- package/dist/types/rules/html-aria-role-must-be-valid.d.ts +5 -4
- package/dist/types/rules/html-attribute-double-quotes.d.ts +5 -4
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +5 -4
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +5 -4
- package/dist/types/rules/html-img-require-alt.d.ts +5 -4
- package/dist/types/rules/html-no-block-inside-inline.d.ts +5 -4
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +5 -4
- package/dist/types/rules/html-no-duplicate-ids.d.ts +5 -4
- package/dist/types/rules/html-no-empty-headings.d.ts +5 -4
- package/dist/types/rules/html-no-nested-links.d.ts +5 -4
- package/dist/types/rules/html-tag-name-lowercase.d.ts +5 -4
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/rules/rule-utils.d.ts +73 -4
- package/dist/types/rules/svg-tag-name-capitalization.d.ts +5 -4
- package/dist/types/src/cli/argument-parser.d.ts +2 -1
- package/dist/types/src/cli/file-processor.d.ts +6 -5
- package/dist/types/src/cli/formatters/base-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +2 -2
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +12 -0
- package/dist/types/src/cli/formatters/index.d.ts +2 -0
- package/dist/types/src/cli/formatters/json-formatter.d.ts +42 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +2 -2
- package/dist/types/src/cli/output-manager.d.ts +31 -0
- package/dist/types/src/cli/summary-reporter.d.ts +3 -3
- package/dist/types/src/cli.d.ts +3 -1
- package/dist/types/src/linter.d.ts +20 -5
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +5 -4
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +5 -4
- package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +7 -0
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +5 -4
- package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +6 -0
- package/dist/types/src/rules/html-anchor-require-href.d.ts +5 -4
- package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +5 -4
- package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +7 -0
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +5 -4
- package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +5 -4
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +5 -4
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +5 -4
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +5 -4
- package/dist/types/src/rules/html-img-require-alt.d.ts +5 -4
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +5 -4
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +5 -4
- package/dist/types/src/rules/html-no-duplicate-ids.d.ts +5 -4
- package/dist/types/src/rules/html-no-empty-headings.d.ts +5 -4
- package/dist/types/src/rules/html-no-nested-links.d.ts +5 -4
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +5 -4
- package/dist/types/src/rules/index.d.ts +3 -0
- package/dist/types/src/rules/parser-no-errors.d.ts +8 -0
- package/dist/types/src/rules/rule-utils.d.ts +73 -4
- package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +5 -4
- package/dist/types/src/types.d.ts +50 -7
- package/dist/types/types.d.ts +50 -7
- package/docs/rules/README.md +5 -1
- package/docs/rules/erb-prefer-image-tag-helper.md +65 -0
- package/docs/rules/erb-requires-trailing-newline.md +37 -0
- package/docs/rules/html-anchor-require-href.md +1 -1
- package/docs/rules/html-aria-level-must-be-valid.md +37 -0
- package/docs/rules/parser-no-errors.md +84 -0
- package/package.json +4 -4
- package/src/cli/argument-parser.ts +33 -19
- package/src/cli/file-processor.ts +27 -21
- package/src/cli/formatters/base-formatter.ts +2 -2
- package/src/cli/formatters/detailed-formatter.ts +9 -9
- package/src/cli/formatters/github-actions-formatter.ts +70 -0
- package/src/cli/formatters/index.ts +2 -0
- package/src/cli/formatters/json-formatter.ts +107 -0
- package/src/cli/formatters/simple-formatter.ts +15 -15
- package/src/cli/output-manager.ts +143 -0
- package/src/cli/summary-reporter.ts +24 -24
- package/src/cli.ts +48 -31
- package/src/default-rules.ts +8 -0
- package/src/linter.ts +42 -8
- package/src/rules/erb-no-empty-tags.ts +7 -6
- package/src/rules/erb-no-output-control-flow.ts +8 -6
- package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
- package/src/rules/erb-require-whitespace-inside-tags.ts +7 -6
- package/src/rules/erb-requires-trailing-newline.ts +29 -0
- package/src/rules/html-anchor-require-href.ts +7 -6
- package/src/rules/html-aria-attribute-must-be-valid.ts +7 -7
- package/src/rules/html-aria-level-must-be-valid.ts +42 -0
- package/src/rules/html-aria-role-heading-requires-level.ts +7 -6
- package/src/rules/html-aria-role-must-be-valid.ts +7 -6
- package/src/rules/html-attribute-double-quotes.ts +7 -6
- package/src/rules/html-attribute-values-require-quotes.ts +7 -6
- package/src/rules/html-boolean-attributes-no-value.ts +7 -6
- package/src/rules/html-img-require-alt.ts +7 -6
- package/src/rules/html-no-block-inside-inline.ts +9 -8
- package/src/rules/html-no-duplicate-attributes.ts +7 -6
- package/src/rules/html-no-duplicate-ids.ts +7 -7
- package/src/rules/html-no-empty-headings.ts +7 -6
- package/src/rules/html-no-nested-links.ts +7 -6
- package/src/rules/html-tag-name-lowercase.ts +7 -6
- package/src/rules/index.ts +3 -0
- package/src/rules/parser-no-errors.ts +25 -0
- package/src/rules/rule-utils.ts +156 -4
- package/src/rules/svg-tag-name-capitalization.ts +7 -6
- package/src/types.ts +61 -7
|
@@ -6,34 +6,34 @@ import type { Diagnostic } from "@herb-tools/core"
|
|
|
6
6
|
import type { ProcessedFile } from "../file-processor.js"
|
|
7
7
|
|
|
8
8
|
export class SimpleFormatter extends BaseFormatter {
|
|
9
|
-
async format(
|
|
10
|
-
if (
|
|
9
|
+
async format(allOffenses: ProcessedFile[]): Promise<void> {
|
|
10
|
+
if (allOffenses.length === 0) return
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const groupedOffenses = new Map<string, Diagnostic[]>()
|
|
13
13
|
|
|
14
|
-
for (const { filename,
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
for (const { filename, offense } of allOffenses) {
|
|
15
|
+
const offenses = groupedOffenses.get(filename) || []
|
|
16
|
+
offenses.push(offense)
|
|
17
|
+
groupedOffenses.set(filename, offenses)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
for (const [filename,
|
|
20
|
+
for (const [filename, offenses] of groupedOffenses) {
|
|
21
21
|
console.log("")
|
|
22
|
-
this.formatFile(filename,
|
|
22
|
+
this.formatFile(filename, offenses)
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
formatFile(filename: string,
|
|
26
|
+
formatFile(filename: string, offenses: Diagnostic[]): void {
|
|
27
27
|
console.log(`${colorize(filename, "cyan")}:`)
|
|
28
28
|
|
|
29
|
-
for (const
|
|
30
|
-
const isError =
|
|
29
|
+
for (const offense of offenses) {
|
|
30
|
+
const isError = offense.severity === "error"
|
|
31
31
|
const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow")
|
|
32
|
-
const rule = colorize(`(${
|
|
33
|
-
const locationString = `${
|
|
32
|
+
const rule = colorize(`(${offense.code})`, "blue")
|
|
33
|
+
const locationString = `${offense.location.start.line}:${offense.location.start.column}`
|
|
34
34
|
const paddedLocation = locationString.padEnd(4) // Pad to 4 characters for alignment
|
|
35
35
|
|
|
36
|
-
console.log(` ${colorize(paddedLocation, "gray")} ${severity} ${
|
|
36
|
+
console.log(` ${colorize(paddedLocation, "gray")} ${severity} ${offense.message} ${rule}`)
|
|
37
37
|
}
|
|
38
38
|
console.log("")
|
|
39
39
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { SummaryReporter } from "./summary-reporter.js"
|
|
2
|
+
import { SimpleFormatter, DetailedFormatter, GitHubActionsFormatter, type JSONOutput } from "./formatters/index.js"
|
|
3
|
+
|
|
4
|
+
import type { ThemeInput } from "@herb-tools/highlighter"
|
|
5
|
+
import type { FormatOption } from "./argument-parser.js"
|
|
6
|
+
import type { ProcessingResult } from "./file-processor.js"
|
|
7
|
+
|
|
8
|
+
interface OutputOptions {
|
|
9
|
+
formatOption: FormatOption
|
|
10
|
+
theme: ThemeInput
|
|
11
|
+
wrapLines: boolean
|
|
12
|
+
truncateLines: boolean
|
|
13
|
+
showTiming: boolean
|
|
14
|
+
startTime: number
|
|
15
|
+
startDate: Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LintResults extends ProcessingResult {
|
|
19
|
+
files: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class OutputManager {
|
|
23
|
+
private summaryReporter = new SummaryReporter()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Output successful lint results
|
|
27
|
+
*/
|
|
28
|
+
async outputResults(results: LintResults, options: OutputOptions): Promise<void> {
|
|
29
|
+
const { allOffenses, files, totalErrors, totalWarnings, filesWithOffenses, ruleCount, ruleOffenses } = results
|
|
30
|
+
|
|
31
|
+
if (options.formatOption === "github") {
|
|
32
|
+
const formatter = new GitHubActionsFormatter()
|
|
33
|
+
await formatter.format(allOffenses)
|
|
34
|
+
} else if (options.formatOption === "json") {
|
|
35
|
+
const output: JSONOutput = {
|
|
36
|
+
offenses: allOffenses.map(({ filename, offense }) => ({
|
|
37
|
+
filename,
|
|
38
|
+
message: offense.message,
|
|
39
|
+
location: offense.location.toJSON(),
|
|
40
|
+
severity: offense.severity,
|
|
41
|
+
code: offense.code,
|
|
42
|
+
source: offense.source
|
|
43
|
+
})),
|
|
44
|
+
summary: {
|
|
45
|
+
filesChecked: files.length,
|
|
46
|
+
filesWithOffenses,
|
|
47
|
+
totalErrors,
|
|
48
|
+
totalWarnings,
|
|
49
|
+
totalOffenses: totalErrors + totalWarnings,
|
|
50
|
+
ruleCount
|
|
51
|
+
},
|
|
52
|
+
timing: null,
|
|
53
|
+
completed: true,
|
|
54
|
+
clean: totalErrors === 0 && totalWarnings === 0,
|
|
55
|
+
message: null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const duration = Date.now() - options.startTime
|
|
59
|
+
output.timing = options.showTiming ? {
|
|
60
|
+
startTime: options.startDate.toISOString(),
|
|
61
|
+
duration: duration
|
|
62
|
+
} : null
|
|
63
|
+
|
|
64
|
+
console.log(JSON.stringify(output, null, 2))
|
|
65
|
+
} else {
|
|
66
|
+
const formatter = options.formatOption === "simple"
|
|
67
|
+
? new SimpleFormatter()
|
|
68
|
+
: new DetailedFormatter(options.theme, options.wrapLines, options.truncateLines)
|
|
69
|
+
|
|
70
|
+
await formatter.format(allOffenses, files.length === 1)
|
|
71
|
+
|
|
72
|
+
this.summaryReporter.displayMostViolatedRules(ruleOffenses)
|
|
73
|
+
this.summaryReporter.displaySummary({
|
|
74
|
+
files,
|
|
75
|
+
totalErrors,
|
|
76
|
+
totalWarnings,
|
|
77
|
+
filesWithOffenses,
|
|
78
|
+
ruleCount,
|
|
79
|
+
startTime: options.startTime,
|
|
80
|
+
startDate: options.startDate,
|
|
81
|
+
showTiming: options.showTiming,
|
|
82
|
+
ruleOffenses
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Output informational message (like "no files found")
|
|
89
|
+
*/
|
|
90
|
+
outputInfo(message: string, options: OutputOptions): void {
|
|
91
|
+
if (options.formatOption === "github") {
|
|
92
|
+
// GitHub Actions format doesn't output anything for info messages
|
|
93
|
+
} else if (options.formatOption === "json") {
|
|
94
|
+
const output: JSONOutput = {
|
|
95
|
+
offenses: [],
|
|
96
|
+
summary: {
|
|
97
|
+
filesChecked: 0,
|
|
98
|
+
filesWithOffenses: 0,
|
|
99
|
+
totalErrors: 0,
|
|
100
|
+
totalWarnings: 0,
|
|
101
|
+
totalOffenses: 0,
|
|
102
|
+
ruleCount: 0
|
|
103
|
+
},
|
|
104
|
+
timing: null,
|
|
105
|
+
completed: false,
|
|
106
|
+
clean: null,
|
|
107
|
+
message
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const duration = Date.now() - options.startTime
|
|
111
|
+
output.timing = options.showTiming ? {
|
|
112
|
+
startTime: options.startDate.toISOString(),
|
|
113
|
+
duration: duration
|
|
114
|
+
} : null
|
|
115
|
+
|
|
116
|
+
console.log(JSON.stringify(output, null, 2))
|
|
117
|
+
} else {
|
|
118
|
+
console.log(message)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Output error message
|
|
124
|
+
*/
|
|
125
|
+
outputError(message: string, options: OutputOptions): void {
|
|
126
|
+
if (options.formatOption === "github") {
|
|
127
|
+
console.log(`::error::${message}`)
|
|
128
|
+
} else if (options.formatOption === "json") {
|
|
129
|
+
const output: JSONOutput = {
|
|
130
|
+
offenses: [],
|
|
131
|
+
summary: null,
|
|
132
|
+
timing: null,
|
|
133
|
+
completed: false,
|
|
134
|
+
clean: null,
|
|
135
|
+
message
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(JSON.stringify(output, null, 2))
|
|
139
|
+
} else {
|
|
140
|
+
console.error(message)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -4,12 +4,12 @@ export interface SummaryData {
|
|
|
4
4
|
files: string[]
|
|
5
5
|
totalErrors: number
|
|
6
6
|
totalWarnings: number
|
|
7
|
-
|
|
7
|
+
filesWithOffenses: number
|
|
8
8
|
ruleCount: number
|
|
9
9
|
startTime: number
|
|
10
10
|
startDate: Date
|
|
11
11
|
showTiming: boolean
|
|
12
|
-
|
|
12
|
+
ruleOffenses: Map<string, { count: number, files: Set<string> }>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export class SummaryReporter {
|
|
@@ -18,13 +18,13 @@ export class SummaryReporter {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
displaySummary(data: SummaryData): void {
|
|
21
|
-
const { files, totalErrors, totalWarnings,
|
|
21
|
+
const { files, totalErrors, totalWarnings, filesWithOffenses, ruleCount, startTime, startDate, showTiming } = data
|
|
22
22
|
|
|
23
23
|
console.log("\n")
|
|
24
24
|
console.log(` ${colorize("Summary:", "bold")}`)
|
|
25
25
|
|
|
26
26
|
// Calculate padding for alignment
|
|
27
|
-
const labelWidth = 12 // Width for the longest label "
|
|
27
|
+
const labelWidth = 12 // Width for the longest label "Offenses"
|
|
28
28
|
const pad = (label: string) => label.padEnd(labelWidth)
|
|
29
29
|
|
|
30
30
|
// Checked summary
|
|
@@ -33,13 +33,13 @@ export class SummaryReporter {
|
|
|
33
33
|
// Files summary (for multiple files)
|
|
34
34
|
if (files.length > 1) {
|
|
35
35
|
const filesChecked = files.length
|
|
36
|
-
const filesClean = filesChecked -
|
|
36
|
+
const filesClean = filesChecked - filesWithOffenses
|
|
37
37
|
|
|
38
38
|
let filesSummary = ""
|
|
39
39
|
let shouldDim = false
|
|
40
40
|
|
|
41
|
-
if (
|
|
42
|
-
filesSummary = `${colorize(colorize(`${
|
|
41
|
+
if (filesWithOffenses > 0) {
|
|
42
|
+
filesSummary = `${colorize(colorize(`${filesWithOffenses} with offenses`, "brightRed"), "bold")} | ${colorize(colorize(`${filesClean} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}`
|
|
43
43
|
} else {
|
|
44
44
|
filesSummary = `${colorize(colorize(`${filesChecked} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}`
|
|
45
45
|
shouldDim = true
|
|
@@ -52,8 +52,8 @@ export class SummaryReporter {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
let
|
|
55
|
+
// Offenses summary with file count
|
|
56
|
+
let offensesSummary = ""
|
|
57
57
|
const parts = []
|
|
58
58
|
|
|
59
59
|
// Build the main part with errors and warnings
|
|
@@ -69,22 +69,22 @@ export class SummaryReporter {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
if (parts.length === 0) {
|
|
72
|
-
|
|
72
|
+
offensesSummary = colorize(colorize("0 offenses", "green"), "bold")
|
|
73
73
|
} else {
|
|
74
|
-
|
|
74
|
+
offensesSummary = parts.join(" | ")
|
|
75
75
|
// Add total count and file count
|
|
76
76
|
let detailText = ""
|
|
77
77
|
|
|
78
|
-
const
|
|
78
|
+
const totalOffenses = totalErrors + totalWarnings
|
|
79
79
|
|
|
80
|
-
if (
|
|
81
|
-
detailText = `${
|
|
80
|
+
if (filesWithOffenses > 0) {
|
|
81
|
+
detailText = `${totalOffenses} ${this.pluralize(totalOffenses, "offense")} across ${filesWithOffenses} ${this.pluralize(filesWithOffenses, "file")}`
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
offensesSummary += ` ${colorize(colorize(`(${detailText})`, "gray"), "dim")}`
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
console.log(` ${colorize(pad("
|
|
87
|
+
console.log(` ${colorize(pad("Offenses"), "gray")} ${offensesSummary}`)
|
|
88
88
|
|
|
89
89
|
// Timing information (if enabled)
|
|
90
90
|
if (showTiming) {
|
|
@@ -96,32 +96,32 @@ export class SummaryReporter {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// Success message for all files clean
|
|
99
|
-
if (
|
|
99
|
+
if (filesWithOffenses === 0 && files.length > 1) {
|
|
100
100
|
console.log("")
|
|
101
101
|
console.log(` ${colorize("✓", "brightGreen")} ${colorize("All files are clean!", "green")}`)
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
displayMostViolatedRules(
|
|
106
|
-
if (
|
|
105
|
+
displayMostViolatedRules(ruleOffenses: Map<string, { count: number, files: Set<string> }>, limit: number = 5): void {
|
|
106
|
+
if (ruleOffenses.size === 0) return
|
|
107
107
|
|
|
108
|
-
const allRules = Array.from(
|
|
108
|
+
const allRules = Array.from(ruleOffenses.entries()).sort((a, b) => b[1].count - a[1].count)
|
|
109
109
|
const displayedRules = allRules.slice(0, limit)
|
|
110
110
|
const remainingRules = allRules.slice(limit)
|
|
111
111
|
|
|
112
|
-
const title =
|
|
112
|
+
const title = ruleOffenses.size <= limit ? "Rule offenses:" : "Most frequent rule offenses:"
|
|
113
113
|
console.log(` ${colorize(title, "bold")}`)
|
|
114
114
|
|
|
115
115
|
for (const [rule, data] of displayedRules) {
|
|
116
116
|
const fileCount = data.files.size
|
|
117
|
-
const countText = `(${data.count} ${this.pluralize(data.count, "
|
|
117
|
+
const countText = `(${data.count} ${this.pluralize(data.count, "offense")} in ${fileCount} ${this.pluralize(fileCount, "file")})`
|
|
118
118
|
console.log(` ${colorize(rule, "gray")} ${colorize(colorize(countText, "gray"), "dim")}`)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
if (remainingRules.length > 0) {
|
|
122
|
-
const
|
|
122
|
+
const remainingOffenseCount = remainingRules.reduce((sum, [_, data]) => sum + data.count, 0)
|
|
123
123
|
const remainingRuleCount = remainingRules.length
|
|
124
|
-
console.log(colorize(colorize(`\n ...and ${remainingRuleCount} more ${this.pluralize(remainingRuleCount, "rule")} with ${
|
|
124
|
+
console.log(colorize(colorize(`\n ...and ${remainingRuleCount} more ${this.pluralize(remainingRuleCount, "rule")} with ${remainingOffenseCount} ${this.pluralize(remainingOffenseCount, "offense")}`, "gray"), "dim"))
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
import { glob } from "glob"
|
|
2
2
|
import { Herb } from "@herb-tools/node-wasm"
|
|
3
|
-
import { ArgumentParser } from "./cli/argument-parser.js"
|
|
3
|
+
import { ArgumentParser, type FormatOption } from "./cli/argument-parser.js"
|
|
4
4
|
import { FileProcessor } from "./cli/file-processor.js"
|
|
5
|
-
import {
|
|
6
|
-
import { SummaryReporter } from "./cli/summary-reporter.js"
|
|
5
|
+
import { OutputManager } from "./cli/output-manager.js"
|
|
7
6
|
|
|
8
7
|
export class CLI {
|
|
9
8
|
private argumentParser = new ArgumentParser()
|
|
10
9
|
private fileProcessor = new FileProcessor()
|
|
11
|
-
private
|
|
10
|
+
private outputManager = new OutputManager()
|
|
11
|
+
|
|
12
|
+
private exitWithError(message: string, formatOption: FormatOption, exitCode: number = 1) {
|
|
13
|
+
this.outputManager.outputError(message, {
|
|
14
|
+
formatOption,
|
|
15
|
+
theme: 'auto',
|
|
16
|
+
wrapLines: false,
|
|
17
|
+
truncateLines: false,
|
|
18
|
+
showTiming: false,
|
|
19
|
+
startTime: 0,
|
|
20
|
+
startDate: new Date()
|
|
21
|
+
})
|
|
22
|
+
process.exit(exitCode)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private exitWithInfo(message: string, formatOption: FormatOption, exitCode: number = 0, timingData?: { startTime: number, startDate: Date, showTiming: boolean }) {
|
|
26
|
+
const outputOptions = {
|
|
27
|
+
formatOption,
|
|
28
|
+
theme: 'auto' as const,
|
|
29
|
+
wrapLines: false,
|
|
30
|
+
truncateLines: false,
|
|
31
|
+
showTiming: timingData?.showTiming ?? false,
|
|
32
|
+
startTime: timingData?.startTime ?? Date.now(),
|
|
33
|
+
startDate: timingData?.startDate ?? new Date()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.outputManager.outputInfo(message, outputOptions)
|
|
37
|
+
process.exit(exitCode)
|
|
38
|
+
}
|
|
12
39
|
|
|
13
40
|
async run() {
|
|
14
41
|
const startTime = Date.now()
|
|
@@ -16,45 +43,35 @@ export class CLI {
|
|
|
16
43
|
|
|
17
44
|
const { pattern, formatOption, showTiming, theme, wrapLines, truncateLines } = this.argumentParser.parse(process.argv)
|
|
18
45
|
|
|
46
|
+
const outputOptions = {
|
|
47
|
+
formatOption,
|
|
48
|
+
theme,
|
|
49
|
+
wrapLines,
|
|
50
|
+
truncateLines,
|
|
51
|
+
showTiming,
|
|
52
|
+
startTime,
|
|
53
|
+
startDate
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
try {
|
|
20
57
|
await Herb.load()
|
|
21
58
|
|
|
22
59
|
const files = await glob(pattern)
|
|
23
60
|
|
|
24
61
|
if (files.length === 0) {
|
|
25
|
-
|
|
26
|
-
process.exit(0)
|
|
62
|
+
this.exitWithInfo(`No files found matching pattern: ${pattern}`, formatOption, 0, { startTime, startDate, showTiming })
|
|
27
63
|
}
|
|
28
64
|
|
|
29
|
-
const results = await this.fileProcessor.processFiles(files)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
: new DetailedFormatter(theme, wrapLines, truncateLines)
|
|
35
|
-
|
|
36
|
-
await formatter.format(allDiagnostics, files.length === 1)
|
|
37
|
-
|
|
38
|
-
this.summaryReporter.displayMostViolatedRules(ruleViolations)
|
|
39
|
-
this.summaryReporter.displaySummary({
|
|
40
|
-
files,
|
|
41
|
-
totalErrors,
|
|
42
|
-
totalWarnings,
|
|
43
|
-
filesWithViolations: filesWithIssues,
|
|
44
|
-
ruleCount,
|
|
45
|
-
startTime,
|
|
46
|
-
startDate,
|
|
47
|
-
showTiming,
|
|
48
|
-
ruleViolations
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
if (totalErrors > 0) {
|
|
65
|
+
const results = await this.fileProcessor.processFiles(files, formatOption)
|
|
66
|
+
|
|
67
|
+
await this.outputManager.outputResults({ ...results, files }, outputOptions)
|
|
68
|
+
|
|
69
|
+
if (results.totalErrors > 0) {
|
|
52
70
|
process.exit(1)
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
} catch (error) {
|
|
56
|
-
|
|
57
|
-
process.exit(1)
|
|
74
|
+
this.exitWithError(`Error: ${error}`, formatOption)
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
}
|
package/src/default-rules.ts
CHANGED
|
@@ -2,9 +2,12 @@ import type { RuleClass } from "./types.js"
|
|
|
2
2
|
|
|
3
3
|
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
|
|
4
4
|
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
|
|
5
|
+
import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js"
|
|
6
|
+
import { ERBRequiresTrailingNewlineRule } from "./rules/erb-requires-trailing-newline.js"
|
|
5
7
|
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
|
|
6
8
|
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
|
|
7
9
|
import { HTMLAriaAttributeMustBeValid } from "./rules/html-aria-attribute-must-be-valid.js"
|
|
10
|
+
import { HTMLAriaLevelMustBeValidRule } from "./rules/html-aria-level-must-be-valid.js"
|
|
8
11
|
import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
|
|
9
12
|
import { HTMLAriaRoleMustBeValidRule } from "./rules/html-aria-role-must-be-valid.js"
|
|
10
13
|
import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
|
|
@@ -17,14 +20,18 @@ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
|
|
|
17
20
|
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
|
|
18
21
|
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
|
|
19
22
|
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
|
|
23
|
+
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
|
|
20
24
|
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
|
|
21
25
|
|
|
22
26
|
export const defaultRules: RuleClass[] = [
|
|
23
27
|
ERBNoEmptyTagsRule,
|
|
24
28
|
ERBNoOutputControlFlowRule,
|
|
29
|
+
ERBPreferImageTagHelperRule,
|
|
30
|
+
ERBRequiresTrailingNewlineRule,
|
|
25
31
|
ERBRequireWhitespaceRule,
|
|
26
32
|
HTMLAnchorRequireHrefRule,
|
|
27
33
|
HTMLAriaAttributeMustBeValid,
|
|
34
|
+
HTMLAriaLevelMustBeValidRule,
|
|
28
35
|
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
29
36
|
HTMLAriaRoleMustBeValidRule,
|
|
30
37
|
HTMLAttributeDoubleQuotesRule,
|
|
@@ -37,5 +44,6 @@ export const defaultRules: RuleClass[] = [
|
|
|
37
44
|
HTMLNoEmptyHeadingsRule,
|
|
38
45
|
HTMLNoNestedLinksRule,
|
|
39
46
|
HTMLTagNameLowercaseRule,
|
|
47
|
+
ParserNoErrorsRule,
|
|
40
48
|
SVGTagNameCapitalizationRule,
|
|
41
49
|
]
|
package/src/linter.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { defaultRules } from "./default-rules.js"
|
|
2
2
|
|
|
3
|
-
import type { RuleClass, LintResult, LintOffense } from "./types.js"
|
|
4
|
-
import type {
|
|
3
|
+
import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js"
|
|
4
|
+
import type { HerbBackend } from "@herb-tools/core"
|
|
5
5
|
|
|
6
6
|
export class Linter {
|
|
7
7
|
private rules: RuleClass[]
|
|
8
|
+
private herb: HerbBackend
|
|
8
9
|
private offenses: LintOffense[]
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Creates a new Linter instance.
|
|
12
|
-
* @param
|
|
13
|
+
* @param herb - The Herb backend instance for parsing and lexing
|
|
14
|
+
* @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
|
|
13
15
|
*/
|
|
14
|
-
constructor(rules?: RuleClass[]) {
|
|
16
|
+
constructor(herb: HerbBackend, rules?: RuleClass[]) {
|
|
17
|
+
this.herb = herb
|
|
15
18
|
this.rules = rules !== undefined ? rules : this.getDefaultRules()
|
|
16
19
|
this.offenses = []
|
|
17
20
|
}
|
|
@@ -28,12 +31,43 @@ export class Linter {
|
|
|
28
31
|
return this.rules.length
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Type guard to check if a rule is a LexerRule
|
|
36
|
+
*/
|
|
37
|
+
private isLexerRule(rule: Rule): rule is LexerRule {
|
|
38
|
+
return (rule.constructor as any).type === "lexer"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Type guard to check if a rule is a SourceRule
|
|
43
|
+
*/
|
|
44
|
+
private isSourceRule(rule: Rule): rule is SourceRule {
|
|
45
|
+
return (rule.constructor as any).type === "source"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lint source code using Parser/AST, Lexer, and Source rules.
|
|
50
|
+
* @param source - The source code to lint
|
|
51
|
+
* @param context - Optional context for linting (e.g., fileName for distinguishing files vs snippets)
|
|
52
|
+
*/
|
|
53
|
+
lint(source: string, context?: Partial<LintContext>): LintResult {
|
|
32
54
|
this.offenses = []
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
const parseResult = this.herb.parse(source)
|
|
57
|
+
const lexResult = this.herb.lex(source)
|
|
58
|
+
|
|
59
|
+
for (const RuleClass of this.rules) {
|
|
60
|
+
const rule = new RuleClass()
|
|
61
|
+
|
|
62
|
+
let ruleOffenses: LintOffense[]
|
|
63
|
+
|
|
64
|
+
if (this.isLexerRule(rule)) {
|
|
65
|
+
ruleOffenses = (rule as LexerRule).check(lexResult, context)
|
|
66
|
+
} else if (this.isSourceRule(rule)) {
|
|
67
|
+
ruleOffenses = (rule as SourceRule).check(source, context)
|
|
68
|
+
} else {
|
|
69
|
+
ruleOffenses = (rule as ParserRule).check(parseResult, context)
|
|
70
|
+
}
|
|
37
71
|
|
|
38
72
|
this.offenses.push(...ruleOffenses)
|
|
39
73
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import type {
|
|
3
|
+
import { ParserRule } from "../types.js"
|
|
4
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
|
+
import type { ParseResult, ERBContentNode } from "@herb-tools/core"
|
|
5
6
|
|
|
6
7
|
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
7
8
|
visitERBContentNode(node: ERBContentNode): void {
|
|
@@ -21,13 +22,13 @@ class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export class ERBNoEmptyTagsRule
|
|
25
|
+
export class ERBNoEmptyTagsRule extends ParserRule {
|
|
25
26
|
name = "erb-no-empty-tags"
|
|
26
27
|
|
|
27
|
-
check(
|
|
28
|
-
const visitor = new ERBNoEmptyTagsVisitor(this.name)
|
|
28
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
29
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.name, context)
|
|
29
30
|
|
|
30
|
-
visitor.visit(
|
|
31
|
+
visitor.visit(result.value)
|
|
31
32
|
|
|
32
33
|
return visitor.offenses
|
|
33
34
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import
|
|
3
|
+
import type { ParseResult, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
|
|
4
|
+
import { ParserRule } from "../types.js"
|
|
5
|
+
import type { LintOffense, LintContext } from "../types.js"
|
|
5
6
|
|
|
6
7
|
class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
7
8
|
visitERBIfNode(node: ERBIfNode): void {
|
|
@@ -49,12 +50,13 @@ class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export class ERBNoOutputControlFlowRule
|
|
53
|
+
export class ERBNoOutputControlFlowRule extends ParserRule {
|
|
53
54
|
name = "erb-no-output-control-flow"
|
|
54
|
-
check(node: Node): LintOffense[] {
|
|
55
|
-
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name)
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
check(result: ParseResult, context?: Partial<LintContext>): LintOffense[] {
|
|
57
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.name, context)
|
|
58
|
+
|
|
59
|
+
visitor.visit(result.value)
|
|
58
60
|
|
|
59
61
|
return visitor.offenses
|
|
60
62
|
}
|