@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
@@ -0,0 +1,37 @@
1
+ # Linter Rule: Enforce trailing newline
2
+
3
+ **Rule:** `erb-requires-trailing-newline`
4
+
5
+ ## Description
6
+
7
+ This rule enforces that all HTML+ERB template files end with exactly one trailing newline character. This is a formatting convention widely adopted across many languages and tools.
8
+
9
+ ## Rationale
10
+
11
+ Ensuring HTML+ERB files end with a single trailing newline aligns with POSIX conventions, where text files should end with a newline character.
12
+
13
+ This practice avoids unnecessary diffs from editors or formatters that auto-insert final newlines, improving compatibility with command-line tools and version control systems. It also helps maintain a clean, predictable structure across view files.
14
+
15
+ Trailing newlines are a common convention in Ruby and are enforced by tools like RuboCop and many Git-based workflows.
16
+
17
+ ## Examples
18
+
19
+ ### ✅ Good
20
+
21
+ ```
22
+ <%= render partial: "header" %>
23
+ <%= render partial: "footer" %>
24
+ ```
25
+
26
+ ### 🚫 Bad
27
+
28
+ ```erb
29
+ <%= render partial: "header" %>
30
+ <%= render partial: "footer" %>▌
31
+ ```
32
+
33
+ ## References
34
+
35
+ - [POSIX: Text files and trailing newlines](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_206)
36
+ - [Git: Trailing newlines and diffs](https://git-scm.com/docs/git-diff#_generating_patches_with_p)
37
+ - [EditorConfig: `insert_final_newline`](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#insert_final_newline)
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Description
6
6
 
7
- Disallow the use of anchor tags without anhref attribute in HTML templates. Use if you want to perform an action without having the user navigated to a new URL.
7
+ Disallow the use of anchor tags without an `href` attribute in HTML templates. Use if you want to perform an action without having the user navigated to a new URL.
8
8
 
9
9
  ## Rationale
10
10
 
@@ -0,0 +1,37 @@
1
+ # Linter Rule: `aria-level` must be between 1 and 6
2
+
3
+ **Rule:** `html-aria-level-must-be-valid`
4
+
5
+ ## Description
6
+
7
+ Ensure that the value of the `aria-level` attribute is a valid heading level: an integer between `1` and `6`. This attribute is used with `role="heading"` to indicate a heading level for non-semantic elements like `<div>` or `<span>`.
8
+
9
+ ## Rationale
10
+
11
+ The WAI-ARIA specification defines `aria-level` as an integer between `1` (highest/most important) and `6` (lowest/subheading). Any other value is invalid and may confuse screen readers or fail accessibility audits.
12
+
13
+ ## Examples
14
+
15
+ ### ✅ Good
16
+
17
+ ```erb
18
+ <div role="heading" aria-level="1">Main</div>
19
+ <div role="heading" aria-level="6">Footnote</div>
20
+ ```
21
+
22
+ ### 🚫 Bad
23
+
24
+ ```erb
25
+ <div role="heading" aria-level="-1">Negative</div>
26
+
27
+ <div role="heading" aria-level="0">Main</div>
28
+
29
+ <div role="heading" aria-level="7">Too deep</div>
30
+
31
+ <div role="heading" aria-level="foo">Invalid</div>
32
+ ```
33
+
34
+ ## References
35
+
36
+ - [ARIA: `heading` role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/heading_role)
37
+ - [ARIA: `aria-level` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-level)
@@ -0,0 +1,84 @@
1
+ # Linter Rule: Disallow parser errors in HTML+ERB documents
2
+
3
+ **Rule:** `parser-no-errors`
4
+
5
+ ## Description
6
+
7
+ Report parser errors as linting offenses. This rule surfaces syntax errors, malformed HTML, and other parsing issues that prevent the document from being correctly parsed.
8
+
9
+ ## Rationale
10
+
11
+ Parser errors indicate fundamental structural problems in HTML+ERB documents that can lead to unexpected rendering behavior, accessibility issues, and maintenance difficulties. These errors should be fixed before addressing other linting concerns as they represent invalid markup that browsers may interpret inconsistently.
12
+
13
+ By surfacing parser errors through the linter, developers can catch these critical issues when running lint checks directly, without needing to switch to the language server or other tools.
14
+
15
+ ## Examples
16
+
17
+ ### ✅ Good
18
+
19
+ ```html
20
+ <h2>Welcome to our site</h2>
21
+ <p>This is a paragraph with proper structure.</p>
22
+
23
+ <div class="container">
24
+ <img src="image.jpg" alt="Description">
25
+ </div>
26
+ ```
27
+
28
+ ```erb
29
+ <h2><%= @page.title %></h2>
30
+ <p><%= @page.description %></p>
31
+
32
+ <% if user_signed_in? %>
33
+ <div class="user-section">
34
+ <%= current_user.name %>
35
+ </div>
36
+ <% end %>
37
+ ```
38
+
39
+ ### 🚫 Bad
40
+
41
+ ```html
42
+ <!-- Mismatched closing tag -->
43
+ <h2>Welcome to our site</h3>
44
+
45
+ <!-- Unclosed element -->
46
+ <div>
47
+ <p>This paragraph is never closed
48
+ </div>
49
+
50
+ <!-- Missing opening tag -->
51
+ Some content
52
+ </div>
53
+ ```
54
+
55
+ ```erb
56
+ <!-- Invalid Ruby syntax in ERB -->
57
+ <%= 1 + %>
58
+
59
+ <!-- Mismatched quotes -->
60
+ <div class="container'>Content</div>
61
+
62
+ <!-- Void element with closing tag -->
63
+ <img src="image.jpg" alt="Description"></img>
64
+ ```
65
+
66
+ ## Error Types
67
+
68
+ This rule reports various parser error types:
69
+
70
+ - **`UNCLOSED_ELEMENT_ERROR`**: Elements that are opened but never closed
71
+ - **`MISSING_CLOSING_TAG_ERROR`**: Opening tags without matching closing tags
72
+ - **`MISSING_OPENING_TAG_ERROR`**: Closing tags without matching opening tags
73
+ - **`TAG_NAMES_MISMATCH_ERROR`**: Opening and closing tags with different names
74
+ - **`QUOTES_MISMATCH_ERROR`**: Mismatched quotation marks in attributes
75
+ - **`VOID_ELEMENT_CLOSING_TAG_ERROR`**: Void elements (like `<img>`) with closing tags
76
+ - **`RUBY_PARSE_ERROR`**: Invalid Ruby syntax within ERB tags
77
+ - **`UNEXPECTED_TOKEN_ERROR`**: Unexpected tokens during parsing
78
+ - **`UNEXPECTED_ERROR`**: Other unexpected parsing issues
79
+
80
+ ## References
81
+
82
+ * [HTML Living Standard - Parsing](https://html.spec.whatwg.org/multipage/parsing.html)
83
+ * [W3C HTML Validator](https://validator.w3.org/)
84
+ * [ERB Template Guide](https://guides.rubyonrails.org/layouts_and_rendering.html)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/linter",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "HTML+ERB linter for validating HTML structure and enforcing best practices",
5
5
  "license": "MIT",
6
6
  "homepage": "https://herb-tools.dev",
@@ -33,9 +33,9 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@herb-tools/core": "0.4.2",
37
- "@herb-tools/highlighter": "0.4.2",
38
- "@herb-tools/node-wasm": "0.4.2",
36
+ "@herb-tools/core": "0.5.0",
37
+ "@herb-tools/highlighter": "0.5.0",
38
+ "@herb-tools/node-wasm": "0.5.0",
39
39
  "glob": "^11.0.3"
40
40
  },
41
41
  "files": [
@@ -11,9 +11,11 @@ import type { ThemeInput } from "@herb-tools/highlighter"
11
11
 
12
12
  import { name, version } from "../../package.json"
13
13
 
14
+ export type FormatOption = "simple" | "detailed" | "json" | "github"
15
+
14
16
  export interface ParsedArguments {
15
17
  pattern: string
16
- formatOption: 'simple' | 'detailed'
18
+ formatOption: FormatOption
17
19
  showTiming: boolean
18
20
  theme: ThemeInput
19
21
  wrapLines: boolean
@@ -32,9 +34,11 @@ export class ArgumentParser {
32
34
  Options:
33
35
  -h, --help show help
34
36
  -v, --version show version
35
- --format output format (simple|detailed) [default: detailed]
37
+ --format output format (simple|detailed|json|github) [default: detailed]
36
38
  --simple use simple output format (shortcut for --format simple)
37
- --theme syntax highlighting theme (${THEME_NAMES.join('|')}) or path to custom theme file [default: ${DEFAULT_THEME}]
39
+ --json use JSON output format (shortcut for --format json)
40
+ --github use GitHub Actions output format (shortcut for --format github)
41
+ --theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}]
38
42
  --no-color disable colored output
39
43
  --no-timing hide timing information
40
44
  --no-wrap-lines disable line wrapping
@@ -45,15 +49,17 @@ export class ArgumentParser {
45
49
  const { values, positionals } = parseArgs({
46
50
  args: argv.slice(2),
47
51
  options: {
48
- help: { type: 'boolean', short: 'h' },
49
- version: { type: 'boolean', short: 'v' },
50
- format: { type: 'string' },
51
- simple: { type: 'boolean' },
52
- theme: { type: 'string' },
53
- 'no-color': { type: 'boolean' },
54
- 'no-timing': { type: 'boolean' },
55
- 'no-wrap-lines': { type: 'boolean' },
56
- 'truncate-lines': { type: 'boolean' }
52
+ help: { type: "boolean", short: "h" },
53
+ version: { type: "boolean", short: "v" },
54
+ format: { type: "string" },
55
+ simple: { type: "boolean" },
56
+ json: { type: "boolean" },
57
+ github: { type: "boolean" },
58
+ theme: { type: "string" },
59
+ "no-color": { type: "boolean" },
60
+ "no-timing": { type: "boolean" },
61
+ "no-wrap-lines": { type: "boolean" },
62
+ "truncate-lines": { type: "boolean" }
57
63
  },
58
64
  allowPositionals: true
59
65
  })
@@ -69,8 +75,8 @@ export class ArgumentParser {
69
75
  process.exit(0)
70
76
  }
71
77
 
72
- let formatOption: 'simple' | 'detailed' = 'detailed'
73
- if (values.format && (values.format === "detailed" || values.format === "simple")) {
78
+ let formatOption: FormatOption = "detailed"
79
+ if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json" || values.format === "github")) {
74
80
  formatOption = values.format
75
81
  }
76
82
 
@@ -78,21 +84,29 @@ export class ArgumentParser {
78
84
  formatOption = "simple"
79
85
  }
80
86
 
81
- if (values['no-color']) {
87
+ if (values.json) {
88
+ formatOption = "json"
89
+ }
90
+
91
+ if (values.github) {
92
+ formatOption = "github"
93
+ }
94
+
95
+ if (values["no-color"]) {
82
96
  process.env.NO_COLOR = "1"
83
97
  }
84
98
 
85
- const showTiming = !values['no-timing']
99
+ const showTiming = !values["no-timing"]
86
100
 
87
- let wrapLines = !values['no-wrap-lines']
101
+ let wrapLines = !values["no-wrap-lines"]
88
102
  let truncateLines = false
89
103
 
90
- if (values['truncate-lines']) {
104
+ if (values["truncate-lines"]) {
91
105
  truncateLines = true
92
106
  wrapLines = false
93
107
  }
94
108
 
95
- if (!values['no-wrap-lines'] && values['truncate-lines']) {
109
+ if (!values["no-wrap-lines"] && values["truncate-lines"]) {
96
110
  console.error("Error: Line wrapping and --truncate-lines cannot be used together. Use --no-wrap-lines with --truncate-lines.")
97
111
  process.exit(1)
98
112
  }
@@ -4,32 +4,33 @@ import { Herb } from "@herb-tools/node-wasm"
4
4
  import { Linter } from "../linter.js"
5
5
  import { colorize } from "@herb-tools/highlighter"
6
6
  import type { Diagnostic } from "@herb-tools/core"
7
+ import type { FormatOption } from "./argument-parser.js"
7
8
 
8
9
  export interface ProcessedFile {
9
10
  filename: string
10
- diagnostic: Diagnostic
11
+ offense: Diagnostic
11
12
  content: string
12
13
  }
13
14
 
14
15
  export interface ProcessingResult {
15
16
  totalErrors: number
16
17
  totalWarnings: number
17
- filesWithIssues: number
18
+ filesWithOffenses: number
18
19
  ruleCount: number
19
- allDiagnostics: ProcessedFile[]
20
- ruleViolations: Map<string, { count: number, files: Set<string> }>
20
+ allOffenses: ProcessedFile[]
21
+ ruleOffenses: Map<string, { count: number, files: Set<string> }>
21
22
  }
22
23
 
23
24
  export class FileProcessor {
24
25
  private linter: Linter | null = null
25
26
 
26
- async processFiles(files: string[]): Promise<ProcessingResult> {
27
+ async processFiles(files: string[], formatOption: FormatOption = 'detailed'): Promise<ProcessingResult> {
27
28
  let totalErrors = 0
28
29
  let totalWarnings = 0
29
- let filesWithIssues = 0
30
+ let filesWithOffenses = 0
30
31
  let ruleCount = 0
31
- const allDiagnostics: ProcessedFile[] = []
32
- const ruleViolations = new Map<string, { count: number, files: Set<string> }>()
32
+ const allOffenses: ProcessedFile[] = []
33
+ const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()
33
34
 
34
35
  for (const filename of files) {
35
36
  const filePath = resolve(filename)
@@ -38,49 +39,54 @@ export class FileProcessor {
38
39
  const parseResult = Herb.parse(content)
39
40
 
40
41
  if (parseResult.errors.length > 0) {
41
- console.error(`${colorize(filename, "cyan")} - ${colorize("Parse errors:", "brightRed")}`)
42
+ if (formatOption !== 'json' && formatOption !== 'github') {
43
+ console.error(`${colorize(filename, "cyan")} - ${colorize("Parse errors:", "brightRed")}`)
42
44
 
45
+ for (const error of parseResult.errors) {
46
+ console.error(` ${colorize("✗", "brightRed")} ${error.message}`)
47
+ }
48
+ }
49
+
50
+ // Add parse errors to offenses for JSON output
43
51
  for (const error of parseResult.errors) {
44
- console.error(` ${colorize("✗", "brightRed")} ${error.message}`)
52
+ allOffenses.push({ filename, offense: error, content })
45
53
  }
46
54
 
47
55
  totalErrors++
48
- filesWithIssues++
56
+ filesWithOffenses++
49
57
  continue
50
58
  }
51
59
 
52
60
  if (!this.linter) {
53
- this.linter = new Linter()
61
+ this.linter = new Linter(Herb)
54
62
  }
55
63
 
56
- const lintResult = this.linter.lint(parseResult.value)
64
+ const lintResult = this.linter.lint(content, { fileName: filename })
57
65
 
58
- // Get rule count on first file
59
66
  if (ruleCount === 0) {
60
67
  ruleCount = this.linter.getRuleCount()
61
68
  }
62
69
 
63
70
  if (lintResult.offenses.length === 0) {
64
- if (files.length === 1) {
71
+ if (files.length === 1 && formatOption !== 'json' && formatOption !== 'github') {
65
72
  console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`)
66
73
  }
67
74
  } else {
68
- // Collect messages for later display
69
75
  for (const offense of lintResult.offenses) {
70
- allDiagnostics.push({ filename, diagnostic: offense, content })
76
+ allOffenses.push({ filename, offense: offense, content })
71
77
 
72
- const ruleData = ruleViolations.get(offense.rule) || { count: 0, files: new Set() }
78
+ const ruleData = ruleOffenses.get(offense.rule) || { count: 0, files: new Set() }
73
79
  ruleData.count++
74
80
  ruleData.files.add(filename)
75
- ruleViolations.set(offense.rule, ruleData)
81
+ ruleOffenses.set(offense.rule, ruleData)
76
82
  }
77
83
 
78
84
  totalErrors += lintResult.errors
79
85
  totalWarnings += lintResult.warnings
80
- filesWithIssues++
86
+ filesWithOffenses++
81
87
  }
82
88
  }
83
89
 
84
- return { totalErrors, totalWarnings, filesWithIssues, ruleCount, allDiagnostics, ruleViolations }
90
+ return { totalErrors, totalWarnings, filesWithOffenses, ruleCount, allOffenses, ruleOffenses }
85
91
  }
86
92
  }
@@ -3,9 +3,9 @@ import type { ProcessedFile } from "../file-processor.js"
3
3
 
4
4
  export abstract class BaseFormatter {
5
5
  abstract format(
6
- allDiagnostics: ProcessedFile[],
6
+ allOffenses: ProcessedFile[],
7
7
  isSingleFile?: boolean
8
8
  ): Promise<void>
9
9
 
10
- abstract formatFile(filename: string, diagnostics: Diagnostic[]): void
10
+ abstract formatFile(filename: string, offenses: Diagnostic[]): void
11
11
  }
@@ -18,8 +18,8 @@ export class DetailedFormatter extends BaseFormatter {
18
18
  this.truncateLines = truncateLines
19
19
  }
20
20
 
21
- async format(allDiagnostics: ProcessedFile[], isSingleFile: boolean = false): Promise<void> {
22
- if (allDiagnostics.length === 0) return
21
+ async format(allOffenses: ProcessedFile[], isSingleFile: boolean = false): Promise<void> {
22
+ if (allOffenses.length === 0) return
23
23
 
24
24
  if (!this.highlighter) {
25
25
  this.highlighter = new Highlighter(this.theme)
@@ -28,8 +28,8 @@ export class DetailedFormatter extends BaseFormatter {
28
28
 
29
29
  if (isSingleFile) {
30
30
  // For single file, use inline diagnostics with syntax highlighting
31
- const { filename, content } = allDiagnostics[0]
32
- const diagnostics = allDiagnostics.map(item => item.diagnostic)
31
+ const { filename, content } = allOffenses[0]
32
+ const diagnostics = allOffenses.map(item => item.offense)
33
33
 
34
34
  const highlighted = this.highlighter.highlight(filename, content, {
35
35
  diagnostics: diagnostics,
@@ -42,11 +42,11 @@ export class DetailedFormatter extends BaseFormatter {
42
42
  console.log(`\n${highlighted}`)
43
43
  } else {
44
44
  // For multiple files, show individual diagnostics with syntax highlighting
45
- const totalMessageCount = allDiagnostics.length
45
+ const totalMessageCount = allOffenses.length
46
46
 
47
- for (let i = 0; i < allDiagnostics.length; i++) {
48
- const { filename, diagnostic, content } = allDiagnostics[i]
49
- const formatted = this.highlighter.highlightDiagnostic(filename, diagnostic, content, {
47
+ for (let i = 0; i < allOffenses.length; i++) {
48
+ const { filename, offense, content } = allOffenses[i]
49
+ const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, {
50
50
  contextLines: 2,
51
51
  wrapLines: this.wrapLines,
52
52
  truncateLines: this.truncateLines
@@ -67,7 +67,7 @@ export class DetailedFormatter extends BaseFormatter {
67
67
  }
68
68
  }
69
69
 
70
- formatFile(_filename: string, _diagnostics: Diagnostic[]): void {
70
+ formatFile(_filename: string, _offenses: Diagnostic[]): void {
71
71
  // Not used in detailed formatter
72
72
  throw new Error("formatFile is not implemented for DetailedFormatter")
73
73
  }
@@ -0,0 +1,70 @@
1
+ import { BaseFormatter } from "./base-formatter.js"
2
+
3
+ import type { Diagnostic } from "@herb-tools/core"
4
+ import type { ProcessedFile } from "../file-processor.js"
5
+
6
+ // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
7
+ export class GitHubActionsFormatter extends BaseFormatter {
8
+ private static readonly MESSAGE_ESCAPE_MAP: Record<string, string> = {
9
+ '%': '%25',
10
+ '\n': '%0A',
11
+ '\r': '%0D'
12
+ }
13
+
14
+ private static readonly PARAM_ESCAPE_MAP: Record<string, string> = {
15
+ '%': '%25',
16
+ '\n': '%0A',
17
+ '\r': '%0D',
18
+ ':': '%3A',
19
+ ',': '%2C'
20
+ }
21
+
22
+ async format(allDiagnostics: ProcessedFile[]): Promise<void> {
23
+ for (const { filename, offense } of allDiagnostics) {
24
+ this.formatDiagnostic(filename, offense)
25
+ }
26
+
27
+ if (allDiagnostics.length > 0) {
28
+ console.log()
29
+ }
30
+ }
31
+
32
+ formatFile(filename: string, diagnostics: Diagnostic[]): void {
33
+ for (const diagnostic of diagnostics) {
34
+ this.formatDiagnostic(filename, diagnostic)
35
+ }
36
+ }
37
+
38
+ // GitHub Actions annotation format:
39
+ // ::{level} file={file},line={line},col={col}::{message}
40
+ //
41
+ private formatDiagnostic(filename: string, diagnostic: Diagnostic): void {
42
+ const level = diagnostic.severity === "error" ? "error" : "warning"
43
+ const { line, column } = diagnostic.location.start
44
+
45
+ const escapedFilename = this.escapeParam(filename)
46
+ const message = this.escapeMessage(diagnostic.message)
47
+
48
+ let fullMessage = message
49
+
50
+ if (diagnostic.code) {
51
+ fullMessage += ` [${diagnostic.code}]`
52
+ }
53
+
54
+ console.log(`\n::${level} file=${escapedFilename},line=${line},col=${column}::${fullMessage}`)
55
+ }
56
+
57
+ private escapeMessage(string: string): string {
58
+ return string.replace(
59
+ new RegExp(Object.keys(GitHubActionsFormatter.MESSAGE_ESCAPE_MAP).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'),
60
+ match => GitHubActionsFormatter.MESSAGE_ESCAPE_MAP[match]
61
+ )
62
+ }
63
+
64
+ private escapeParam(string: string): string {
65
+ return string.replace(
66
+ new RegExp(Object.keys(GitHubActionsFormatter.PARAM_ESCAPE_MAP).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'),
67
+ match => GitHubActionsFormatter.PARAM_ESCAPE_MAP[match]
68
+ )
69
+ }
70
+ }
@@ -1,3 +1,5 @@
1
1
  export { BaseFormatter } from "./base-formatter.js"
2
2
  export { SimpleFormatter } from "./simple-formatter.js"
3
3
  export { DetailedFormatter } from "./detailed-formatter.js"
4
+ export { JSONFormatter, type JSONOutput } from "./json-formatter.js"
5
+ export { GitHubActionsFormatter } from "./github-actions-formatter.js"
@@ -0,0 +1,107 @@
1
+ import { BaseFormatter } from "./base-formatter.js"
2
+
3
+ import type { Diagnostic, SerializedDiagnostic } from "@herb-tools/core"
4
+ import type { ProcessedFile } from "../file-processor.js"
5
+
6
+ interface JSONOffense extends SerializedDiagnostic {
7
+ filename: string
8
+ }
9
+
10
+ interface JSONSummary {
11
+ filesChecked: number
12
+ filesWithOffenses: number
13
+ totalErrors: number
14
+ totalWarnings: number
15
+ totalOffenses: number
16
+ ruleCount: number
17
+ }
18
+
19
+ interface JSONTiming {
20
+ startTime: string
21
+ duration: number
22
+ }
23
+
24
+ export interface JSONOutput {
25
+ offenses: JSONOffense[]
26
+ summary: JSONSummary | null
27
+ timing: JSONTiming | null
28
+ completed: boolean
29
+ clean: boolean | null
30
+ message: string | null
31
+ }
32
+
33
+ interface JSONFormatOptions {
34
+ files: string[]
35
+ totalErrors: number
36
+ totalWarnings: number
37
+ filesWithOffenses: number
38
+ ruleCount: number
39
+ startTime: number
40
+ startDate: Date
41
+ showTiming: boolean
42
+ }
43
+
44
+ export class JSONFormatter extends BaseFormatter {
45
+ async format(allOffenses: ProcessedFile[]): Promise<void> {
46
+ const jsonOffenses: JSONOffense[] = allOffenses.map(({ filename, offense }) => ({
47
+ filename,
48
+ message: offense.message,
49
+ location: offense.location.toJSON(),
50
+ severity: offense.severity,
51
+ code: offense.code,
52
+ source: offense.source
53
+ }))
54
+
55
+ const output: JSONOutput = {
56
+ offenses: jsonOffenses,
57
+ summary: null,
58
+ timing: null,
59
+ completed: true,
60
+ clean: jsonOffenses.length === 0,
61
+ message: null
62
+ }
63
+
64
+ console.log(JSON.stringify(output, null, 2))
65
+ }
66
+
67
+ async formatWithSummary(allOffenses: ProcessedFile[], options: JSONFormatOptions): Promise<void> {
68
+ const jsonOffenses: JSONOffense[] = allOffenses.map(({ filename, offense }) => ({
69
+ filename,
70
+ message: offense.message,
71
+ location: offense.location.toJSON(),
72
+ severity: offense.severity,
73
+ code: offense.code,
74
+ source: offense.source
75
+ }))
76
+
77
+ const summary: JSONSummary = {
78
+ filesChecked: options.files.length,
79
+ filesWithOffenses: options.filesWithOffenses,
80
+ totalErrors: options.totalErrors,
81
+ totalWarnings: options.totalWarnings,
82
+ totalOffenses: options.totalErrors + options.totalWarnings,
83
+ ruleCount: options.ruleCount
84
+ }
85
+
86
+ const output: JSONOutput = {
87
+ offenses: jsonOffenses,
88
+ summary,
89
+ timing: null,
90
+ completed: true,
91
+ clean: options.totalErrors === 0 && options.totalWarnings === 0,
92
+ message: null
93
+ }
94
+
95
+ const duration = Date.now() - options.startTime
96
+ output.timing = options.showTiming ? {
97
+ startTime: options.startDate.toISOString(),
98
+ duration: duration
99
+ } : null
100
+
101
+ console.log(JSON.stringify(output, null, 2))
102
+ }
103
+
104
+ formatFile(_filename: string, _offenses: Diagnostic[]): void {
105
+ // Not used in JSON formatter, everything is handled in format()
106
+ }
107
+ }