@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
|
@@ -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
|
|
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.
|
|
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.
|
|
37
|
-
"@herb-tools/highlighter": "0.
|
|
38
|
-
"@herb-tools/node-wasm": "0.
|
|
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:
|
|
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
|
-
--
|
|
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:
|
|
49
|
-
version: { type:
|
|
50
|
-
format: { type:
|
|
51
|
-
simple: { type:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
|
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[
|
|
99
|
+
const showTiming = !values["no-timing"]
|
|
86
100
|
|
|
87
|
-
let wrapLines = !values[
|
|
101
|
+
let wrapLines = !values["no-wrap-lines"]
|
|
88
102
|
let truncateLines = false
|
|
89
103
|
|
|
90
|
-
if (values[
|
|
104
|
+
if (values["truncate-lines"]) {
|
|
91
105
|
truncateLines = true
|
|
92
106
|
wrapLines = false
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
if (!values[
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
filesWithOffenses: number
|
|
18
19
|
ruleCount: number
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
30
|
+
let filesWithOffenses = 0
|
|
30
31
|
let ruleCount = 0
|
|
31
|
-
const
|
|
32
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
allOffenses.push({ filename, offense: error, content })
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
totalErrors++
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
76
|
+
allOffenses.push({ filename, offense: offense, content })
|
|
71
77
|
|
|
72
|
-
const ruleData =
|
|
78
|
+
const ruleData = ruleOffenses.get(offense.rule) || { count: 0, files: new Set() }
|
|
73
79
|
ruleData.count++
|
|
74
80
|
ruleData.files.add(filename)
|
|
75
|
-
|
|
81
|
+
ruleOffenses.set(offense.rule, ruleData)
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
totalErrors += lintResult.errors
|
|
79
85
|
totalWarnings += lintResult.warnings
|
|
80
|
-
|
|
86
|
+
filesWithOffenses++
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
return { totalErrors, totalWarnings,
|
|
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
|
-
|
|
6
|
+
allOffenses: ProcessedFile[],
|
|
7
7
|
isSingleFile?: boolean
|
|
8
8
|
): Promise<void>
|
|
9
9
|
|
|
10
|
-
abstract formatFile(filename: string,
|
|
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(
|
|
22
|
-
if (
|
|
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 } =
|
|
32
|
-
const diagnostics =
|
|
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 =
|
|
45
|
+
const totalMessageCount = allOffenses.length
|
|
46
46
|
|
|
47
|
-
for (let i = 0; i <
|
|
48
|
-
const { filename,
|
|
49
|
-
const formatted = this.highlighter.highlightDiagnostic(filename,
|
|
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,
|
|
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
|
+
}
|