@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.
Files changed (202) hide show
  1. package/README.md +221 -10
  2. package/dist/herb-lint.js +817 -292
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +360 -81
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +355 -83
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +4 -4
  9. package/dist/src/cli/argument-parser.js +28 -18
  10. package/dist/src/cli/argument-parser.js.map +1 -1
  11. package/dist/src/cli/file-processor.js +21 -17
  12. package/dist/src/cli/file-processor.js.map +1 -1
  13. package/dist/src/cli/formatters/detailed-formatter.js +9 -9
  14. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  15. package/dist/src/cli/formatters/github-actions-formatter.js +50 -0
  16. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -0
  17. package/dist/src/cli/formatters/index.js +2 -0
  18. package/dist/src/cli/formatters/index.js.map +1 -1
  19. package/dist/src/cli/formatters/json-formatter.js +58 -0
  20. package/dist/src/cli/formatters/json-formatter.js.map +1 -0
  21. package/dist/src/cli/formatters/simple-formatter.js +15 -15
  22. package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
  23. package/dist/src/cli/output-manager.js +120 -0
  24. package/dist/src/cli/output-manager.js.map +1 -0
  25. package/dist/src/cli/summary-reporter.js +22 -22
  26. package/dist/src/cli/summary-reporter.js.map +1 -1
  27. package/dist/src/cli.js +41 -26
  28. package/dist/src/cli.js.map +1 -1
  29. package/dist/src/default-rules.js +8 -0
  30. package/dist/src/default-rules.js.map +1 -1
  31. package/dist/src/linter.js +37 -6
  32. package/dist/src/linter.js.map +1 -1
  33. package/dist/src/rules/erb-no-empty-tags.js +5 -4
  34. package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
  35. package/dist/src/rules/erb-no-output-control-flow.js +5 -4
  36. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
  37. package/dist/src/rules/erb-prefer-image-tag-helper.js +93 -0
  38. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -0
  39. package/dist/src/rules/erb-require-whitespace-inside-tags.js +5 -4
  40. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  41. package/dist/src/rules/erb-requires-trailing-newline.js +22 -0
  42. package/dist/src/rules/erb-requires-trailing-newline.js.map +1 -0
  43. package/dist/src/rules/html-anchor-require-href.js +5 -4
  44. package/dist/src/rules/html-anchor-require-href.js.map +1 -1
  45. package/dist/src/rules/html-aria-attribute-must-be-valid.js +5 -4
  46. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  47. package/dist/src/rules/html-aria-level-must-be-valid.js +27 -0
  48. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -0
  49. package/dist/src/rules/html-aria-role-heading-requires-level.js +5 -4
  50. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  51. package/dist/src/rules/html-aria-role-must-be-valid.js +5 -4
  52. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  53. package/dist/src/rules/html-attribute-double-quotes.js +5 -4
  54. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  55. package/dist/src/rules/html-attribute-values-require-quotes.js +5 -4
  56. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  57. package/dist/src/rules/html-boolean-attributes-no-value.js +5 -4
  58. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  59. package/dist/src/rules/html-img-require-alt.js +5 -4
  60. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  61. package/dist/src/rules/html-no-block-inside-inline.js +7 -6
  62. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
  63. package/dist/src/rules/html-no-duplicate-attributes.js +5 -4
  64. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  65. package/dist/src/rules/html-no-duplicate-ids.js +5 -4
  66. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  67. package/dist/src/rules/html-no-empty-headings.js +5 -4
  68. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  69. package/dist/src/rules/html-no-nested-links.js +5 -4
  70. package/dist/src/rules/html-no-nested-links.js.map +1 -1
  71. package/dist/src/rules/html-tag-name-lowercase.js +5 -4
  72. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  73. package/dist/src/rules/index.js +3 -0
  74. package/dist/src/rules/index.js.map +1 -1
  75. package/dist/src/rules/parser-no-errors.js +18 -0
  76. package/dist/src/rules/parser-no-errors.js.map +1 -0
  77. package/dist/src/rules/rule-utils.js +125 -2
  78. package/dist/src/rules/rule-utils.js.map +1 -1
  79. package/dist/src/rules/svg-tag-name-capitalization.js +5 -4
  80. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  81. package/dist/src/types.js +15 -1
  82. package/dist/src/types.js.map +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/types/cli/argument-parser.d.ts +2 -1
  85. package/dist/types/cli/file-processor.d.ts +6 -5
  86. package/dist/types/cli/formatters/base-formatter.d.ts +2 -2
  87. package/dist/types/cli/formatters/detailed-formatter.d.ts +2 -2
  88. package/dist/types/cli/formatters/github-actions-formatter.d.ts +12 -0
  89. package/dist/types/cli/formatters/index.d.ts +2 -0
  90. package/dist/types/cli/formatters/json-formatter.d.ts +42 -0
  91. package/dist/types/cli/formatters/simple-formatter.d.ts +2 -2
  92. package/dist/types/cli/output-manager.d.ts +31 -0
  93. package/dist/types/cli/summary-reporter.d.ts +3 -3
  94. package/dist/types/cli.d.ts +3 -1
  95. package/dist/types/linter.d.ts +20 -5
  96. package/dist/types/rules/erb-no-empty-tags.d.ts +5 -4
  97. package/dist/types/rules/erb-no-output-control-flow.d.ts +5 -4
  98. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  99. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +5 -4
  100. package/dist/types/rules/erb-requires-trailing-newline.d.ts +6 -0
  101. package/dist/types/rules/html-anchor-require-href.d.ts +5 -4
  102. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +5 -4
  103. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +7 -0
  104. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +5 -4
  105. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +5 -4
  106. package/dist/types/rules/html-attribute-double-quotes.d.ts +5 -4
  107. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +5 -4
  108. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +5 -4
  109. package/dist/types/rules/html-img-require-alt.d.ts +5 -4
  110. package/dist/types/rules/html-no-block-inside-inline.d.ts +5 -4
  111. package/dist/types/rules/html-no-duplicate-attributes.d.ts +5 -4
  112. package/dist/types/rules/html-no-duplicate-ids.d.ts +5 -4
  113. package/dist/types/rules/html-no-empty-headings.d.ts +5 -4
  114. package/dist/types/rules/html-no-nested-links.d.ts +5 -4
  115. package/dist/types/rules/html-tag-name-lowercase.d.ts +5 -4
  116. package/dist/types/rules/index.d.ts +3 -0
  117. package/dist/types/rules/parser-no-errors.d.ts +8 -0
  118. package/dist/types/rules/rule-utils.d.ts +73 -4
  119. package/dist/types/rules/svg-tag-name-capitalization.d.ts +5 -4
  120. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  121. package/dist/types/src/cli/file-processor.d.ts +6 -5
  122. package/dist/types/src/cli/formatters/base-formatter.d.ts +2 -2
  123. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +2 -2
  124. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +12 -0
  125. package/dist/types/src/cli/formatters/index.d.ts +2 -0
  126. package/dist/types/src/cli/formatters/json-formatter.d.ts +42 -0
  127. package/dist/types/src/cli/formatters/simple-formatter.d.ts +2 -2
  128. package/dist/types/src/cli/output-manager.d.ts +31 -0
  129. package/dist/types/src/cli/summary-reporter.d.ts +3 -3
  130. package/dist/types/src/cli.d.ts +3 -1
  131. package/dist/types/src/linter.d.ts +20 -5
  132. package/dist/types/src/rules/erb-no-empty-tags.d.ts +5 -4
  133. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +5 -4
  134. package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +7 -0
  135. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +5 -4
  136. package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +6 -0
  137. package/dist/types/src/rules/html-anchor-require-href.d.ts +5 -4
  138. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +5 -4
  139. package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +7 -0
  140. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +5 -4
  141. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +5 -4
  142. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +5 -4
  143. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +5 -4
  144. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +5 -4
  145. package/dist/types/src/rules/html-img-require-alt.d.ts +5 -4
  146. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +5 -4
  147. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +5 -4
  148. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +5 -4
  149. package/dist/types/src/rules/html-no-empty-headings.d.ts +5 -4
  150. package/dist/types/src/rules/html-no-nested-links.d.ts +5 -4
  151. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +5 -4
  152. package/dist/types/src/rules/index.d.ts +3 -0
  153. package/dist/types/src/rules/parser-no-errors.d.ts +8 -0
  154. package/dist/types/src/rules/rule-utils.d.ts +73 -4
  155. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +5 -4
  156. package/dist/types/src/types.d.ts +50 -7
  157. package/dist/types/types.d.ts +50 -7
  158. package/docs/rules/README.md +5 -1
  159. package/docs/rules/erb-prefer-image-tag-helper.md +65 -0
  160. package/docs/rules/erb-requires-trailing-newline.md +37 -0
  161. package/docs/rules/html-anchor-require-href.md +1 -1
  162. package/docs/rules/html-aria-level-must-be-valid.md +37 -0
  163. package/docs/rules/parser-no-errors.md +84 -0
  164. package/package.json +4 -4
  165. package/src/cli/argument-parser.ts +33 -19
  166. package/src/cli/file-processor.ts +27 -21
  167. package/src/cli/formatters/base-formatter.ts +2 -2
  168. package/src/cli/formatters/detailed-formatter.ts +9 -9
  169. package/src/cli/formatters/github-actions-formatter.ts +70 -0
  170. package/src/cli/formatters/index.ts +2 -0
  171. package/src/cli/formatters/json-formatter.ts +107 -0
  172. package/src/cli/formatters/simple-formatter.ts +15 -15
  173. package/src/cli/output-manager.ts +143 -0
  174. package/src/cli/summary-reporter.ts +24 -24
  175. package/src/cli.ts +48 -31
  176. package/src/default-rules.ts +8 -0
  177. package/src/linter.ts +42 -8
  178. package/src/rules/erb-no-empty-tags.ts +7 -6
  179. package/src/rules/erb-no-output-control-flow.ts +8 -6
  180. package/src/rules/erb-prefer-image-tag-helper.ts +124 -0
  181. package/src/rules/erb-require-whitespace-inside-tags.ts +7 -6
  182. package/src/rules/erb-requires-trailing-newline.ts +29 -0
  183. package/src/rules/html-anchor-require-href.ts +7 -6
  184. package/src/rules/html-aria-attribute-must-be-valid.ts +7 -7
  185. package/src/rules/html-aria-level-must-be-valid.ts +42 -0
  186. package/src/rules/html-aria-role-heading-requires-level.ts +7 -6
  187. package/src/rules/html-aria-role-must-be-valid.ts +7 -6
  188. package/src/rules/html-attribute-double-quotes.ts +7 -6
  189. package/src/rules/html-attribute-values-require-quotes.ts +7 -6
  190. package/src/rules/html-boolean-attributes-no-value.ts +7 -6
  191. package/src/rules/html-img-require-alt.ts +7 -6
  192. package/src/rules/html-no-block-inside-inline.ts +9 -8
  193. package/src/rules/html-no-duplicate-attributes.ts +7 -6
  194. package/src/rules/html-no-duplicate-ids.ts +7 -7
  195. package/src/rules/html-no-empty-headings.ts +7 -6
  196. package/src/rules/html-no-nested-links.ts +7 -6
  197. package/src/rules/html-tag-name-lowercase.ts +7 -6
  198. package/src/rules/index.ts +3 -0
  199. package/src/rules/parser-no-errors.ts +25 -0
  200. package/src/rules/rule-utils.ts +156 -4
  201. package/src/rules/svg-tag-name-capitalization.ts +7 -6
  202. 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(allDiagnostics: ProcessedFile[]): Promise<void> {
10
- if (allDiagnostics.length === 0) return
9
+ async format(allOffenses: ProcessedFile[]): Promise<void> {
10
+ if (allOffenses.length === 0) return
11
11
 
12
- const groupedDiagnostics = new Map<string, Diagnostic[]>()
12
+ const groupedOffenses = new Map<string, Diagnostic[]>()
13
13
 
14
- for (const { filename, diagnostic } of allDiagnostics) {
15
- const diagnostics = groupedDiagnostics.get(filename) || []
16
- diagnostics.push(diagnostic)
17
- groupedDiagnostics.set(filename, diagnostics)
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, diagnostics] of groupedDiagnostics) {
20
+ for (const [filename, offenses] of groupedOffenses) {
21
21
  console.log("")
22
- this.formatFile(filename, diagnostics)
22
+ this.formatFile(filename, offenses)
23
23
  }
24
24
  }
25
25
 
26
- formatFile(filename: string, diagnostics: Diagnostic[]): void {
26
+ formatFile(filename: string, offenses: Diagnostic[]): void {
27
27
  console.log(`${colorize(filename, "cyan")}:`)
28
28
 
29
- for (const diagnostic of diagnostics) {
30
- const isError = diagnostic.severity === "error"
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(`(${diagnostic.code})`, "blue")
33
- const locationString = `${diagnostic.location.start.line}:${diagnostic.location.start.column}`
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} ${diagnostic.message} ${rule}`)
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
- filesWithViolations: number
7
+ filesWithOffenses: number
8
8
  ruleCount: number
9
9
  startTime: number
10
10
  startDate: Date
11
11
  showTiming: boolean
12
- ruleViolations: Map<string, { count: number, files: Set<string> }>
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, filesWithViolations, ruleCount, startTime, startDate, showTiming } = data
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 "Violations"
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 - filesWithViolations
36
+ const filesClean = filesChecked - filesWithOffenses
37
37
 
38
38
  let filesSummary = ""
39
39
  let shouldDim = false
40
40
 
41
- if (filesWithViolations > 0) {
42
- filesSummary = `${colorize(colorize(`${filesWithViolations} with violations`, "brightRed"), "bold")} | ${colorize(colorize(`${filesClean} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}`
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
- // Violations summary with file count
56
- let violationsSummary = ""
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
- violationsSummary = colorize(colorize("0 violations", "green"), "bold")
72
+ offensesSummary = colorize(colorize("0 offenses", "green"), "bold")
73
73
  } else {
74
- violationsSummary = parts.join(" | ")
74
+ offensesSummary = parts.join(" | ")
75
75
  // Add total count and file count
76
76
  let detailText = ""
77
77
 
78
- const totalViolations = totalErrors + totalWarnings
78
+ const totalOffenses = totalErrors + totalWarnings
79
79
 
80
- if (filesWithViolations > 0) {
81
- detailText = `${totalViolations} ${this.pluralize(totalViolations, "violation")} across ${filesWithViolations} ${this.pluralize(filesWithViolations, "file")}`
80
+ if (filesWithOffenses > 0) {
81
+ detailText = `${totalOffenses} ${this.pluralize(totalOffenses, "offense")} across ${filesWithOffenses} ${this.pluralize(filesWithOffenses, "file")}`
82
82
  }
83
83
 
84
- violationsSummary += ` ${colorize(colorize(`(${detailText})`, "gray"), "dim")}`
84
+ offensesSummary += ` ${colorize(colorize(`(${detailText})`, "gray"), "dim")}`
85
85
  }
86
86
 
87
- console.log(` ${colorize(pad("Violations"), "gray")} ${violationsSummary}`)
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 (filesWithViolations === 0 && files.length > 1) {
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(ruleViolations: Map<string, { count: number, files: Set<string> }>, limit: number = 5): void {
106
- if (ruleViolations.size === 0) return
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(ruleViolations.entries()).sort((a, b) => b[1].count - a[1].count)
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 = ruleViolations.size <= limit ? "Rule violations:" : "Most violated rules:"
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, "violation")} in ${fileCount} ${this.pluralize(fileCount, "file")})`
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 remainingViolationCount = remainingRules.reduce((sum, [_, data]) => sum + data.count, 0)
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 ${remainingViolationCount} ${this.pluralize(remainingViolationCount, "violation")}`, "gray"), "dim"))
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 { SimpleFormatter, DetailedFormatter } from "./cli/formatters/index.js"
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 summaryReporter = new SummaryReporter()
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
- console.log(`No files found matching pattern: ${pattern}`)
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
- const { totalErrors, totalWarnings, filesWithIssues, ruleCount, allDiagnostics, ruleViolations } = results
31
-
32
- const formatter = formatOption === 'simple'
33
- ? new SimpleFormatter()
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
- console.error(`Error:`, error)
57
- process.exit(1)
74
+ this.exitWithError(`Error: ${error}`, formatOption)
58
75
  }
59
76
  }
60
77
  }
@@ -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 { DocumentNode } from "@herb-tools/core"
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 rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
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
- lint(document: DocumentNode): LintResult {
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
- for (const Rule of this.rules) {
35
- const rule = new Rule()
36
- const ruleOffenses = rule.check(document)
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 type { Rule, LintOffense } from "../types.js"
4
- import type { Node, ERBContentNode } from "@herb-tools/core"
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 implements Rule {
25
+ export class ERBNoEmptyTagsRule extends ParserRule {
25
26
  name = "erb-no-empty-tags"
26
27
 
27
- check(node: Node): LintOffense[] {
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(node)
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 { Node, ERBIfNode, ERBUnlessNode, ERBElseNode, ERBEndNode } from "@herb-tools/core"
4
- import type { Rule, LintOffense } from "../types.js"
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 implements Rule {
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
- visitor.visit(node)
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
  }