@herb-tools/linter 0.6.0 → 0.7.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 (99) hide show
  1. package/README.md +60 -16
  2. package/dist/herb-lint.js +1684 -295
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +1226 -158
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +1188 -160
  7. package/dist/index.js.map +1 -1
  8. package/dist/package.json +11 -4
  9. package/dist/src/cli/argument-parser.js +11 -6
  10. package/dist/src/cli/argument-parser.js.map +1 -1
  11. package/dist/src/cli/file-processor.js +5 -6
  12. package/dist/src/cli/file-processor.js.map +1 -1
  13. package/dist/src/cli/formatters/detailed-formatter.js +3 -5
  14. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  15. package/dist/src/cli/formatters/github-actions-formatter.js +55 -11
  16. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
  17. package/dist/src/cli/index.js +1 -0
  18. package/dist/src/cli/index.js.map +1 -1
  19. package/dist/src/cli/output-manager.js +23 -5
  20. package/dist/src/cli/output-manager.js.map +1 -1
  21. package/dist/src/cli/summary-reporter.js +2 -11
  22. package/dist/src/cli/summary-reporter.js.map +1 -1
  23. package/dist/src/cli.js +88 -4
  24. package/dist/src/cli.js.map +1 -1
  25. package/dist/src/default-rules.js +8 -4
  26. package/dist/src/default-rules.js.map +1 -1
  27. package/dist/src/linter.js.map +1 -1
  28. package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
  29. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  30. package/dist/src/rules/html-boolean-attributes-no-value.js +8 -8
  31. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  32. package/dist/src/rules/html-no-duplicate-ids.js +134 -9
  33. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  34. package/dist/src/rules/html-no-empty-attributes.js +56 -0
  35. package/dist/src/rules/html-no-empty-attributes.js.map +1 -0
  36. package/dist/src/rules/html-no-positive-tab-index.js +1 -1
  37. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
  38. package/dist/src/rules/html-no-self-closing.js +12 -5
  39. package/dist/src/rules/html-no-self-closing.js.map +1 -1
  40. package/dist/src/rules/html-no-underscores-in-attribute-names.js +36 -0
  41. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -0
  42. package/dist/src/rules/index.js +3 -0
  43. package/dist/src/rules/index.js.map +1 -1
  44. package/dist/src/rules/rule-utils.js +80 -7
  45. package/dist/src/rules/rule-utils.js.map +1 -1
  46. package/dist/src/rules/svg-tag-name-capitalization.js +2 -2
  47. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/dist/types/cli/argument-parser.d.ts +2 -1
  50. package/dist/types/cli/file-processor.d.ts +6 -1
  51. package/dist/types/cli/formatters/github-actions-formatter.d.ts +6 -1
  52. package/dist/types/cli/index.d.ts +1 -0
  53. package/dist/types/cli/output-manager.d.ts +1 -0
  54. package/dist/types/cli.d.ts +20 -5
  55. package/dist/types/linter.d.ts +7 -7
  56. package/dist/types/rules/html-no-empty-attributes.d.ts +7 -0
  57. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  58. package/dist/types/rules/index.d.ts +3 -0
  59. package/dist/types/rules/rule-utils.d.ts +46 -5
  60. package/dist/types/src/cli/argument-parser.d.ts +2 -1
  61. package/dist/types/src/cli/file-processor.d.ts +6 -1
  62. package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +6 -1
  63. package/dist/types/src/cli/index.d.ts +1 -0
  64. package/dist/types/src/cli/output-manager.d.ts +1 -0
  65. package/dist/types/src/cli.d.ts +20 -5
  66. package/dist/types/src/linter.d.ts +7 -7
  67. package/dist/types/src/rules/html-no-empty-attributes.d.ts +7 -0
  68. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +7 -0
  69. package/dist/types/src/rules/index.d.ts +3 -0
  70. package/dist/types/src/rules/rule-utils.d.ts +46 -5
  71. package/docs/rules/README.md +2 -0
  72. package/docs/rules/html-img-require-alt.md +0 -2
  73. package/docs/rules/html-no-empty-attributes.md +77 -0
  74. package/docs/rules/html-no-underscores-in-attribute-names.md +45 -0
  75. package/package.json +11 -4
  76. package/src/cli/argument-parser.ts +15 -7
  77. package/src/cli/file-processor.ts +11 -7
  78. package/src/cli/formatters/detailed-formatter.ts +5 -7
  79. package/src/cli/formatters/github-actions-formatter.ts +64 -11
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/output-manager.ts +27 -5
  82. package/src/cli/summary-reporter.ts +3 -11
  83. package/src/cli.ts +125 -20
  84. package/src/default-rules.ts +8 -4
  85. package/src/linter.ts +6 -6
  86. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +1 -1
  87. package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
  88. package/src/rules/erb-require-whitespace-inside-tags.ts +2 -2
  89. package/src/rules/html-attribute-double-quotes.ts +1 -1
  90. package/src/rules/html-boolean-attributes-no-value.ts +9 -11
  91. package/src/rules/html-no-duplicate-ids.ts +188 -14
  92. package/src/rules/html-no-empty-attributes.ts +75 -0
  93. package/src/rules/html-no-positive-tab-index.ts +1 -1
  94. package/src/rules/html-no-self-closing.ts +13 -8
  95. package/src/rules/html-no-underscores-in-attribute-names.ts +58 -0
  96. package/src/rules/html-tag-name-lowercase.ts +1 -1
  97. package/src/rules/index.ts +3 -0
  98. package/src/rules/rule-utils.ts +110 -9
  99. package/src/rules/svg-tag-name-capitalization.ts +2 -2
@@ -1,8 +1,23 @@
1
+ import { ArgumentParser, type FormatOption } from "./cli/argument-parser.js";
2
+ import { FileProcessor } from "./cli/file-processor.js";
3
+ import { OutputManager } from "./cli/output-manager.js";
4
+ export * from "./cli/index.js";
1
5
  export declare class CLI {
2
- private argumentParser;
3
- private fileProcessor;
4
- private outputManager;
5
- private exitWithError;
6
- private exitWithInfo;
6
+ protected argumentParser: ArgumentParser;
7
+ protected fileProcessor: FileProcessor;
8
+ protected outputManager: OutputManager;
9
+ protected projectPath: string;
10
+ getProjectPath(): string;
11
+ protected findProjectRoot(startPath: string): string;
12
+ protected exitWithError(message: string, formatOption: FormatOption, exitCode?: number): void;
13
+ protected exitWithInfo(message: string, formatOption: FormatOption, exitCode?: number, timingData?: {
14
+ startTime: number;
15
+ startDate: Date;
16
+ showTiming: boolean;
17
+ }): void;
18
+ protected determineProjectPath(pattern: string | undefined): void;
19
+ protected adjustPattern(pattern: string | undefined): string;
20
+ protected beforeProcess(): Promise<void>;
21
+ protected afterProcess(_results: any, _outputOptions: any): Promise<void>;
7
22
  run(): Promise<void>;
8
23
  }
@@ -1,9 +1,9 @@
1
- import type { RuleClass, LintResult, LintContext } from "./types.js";
1
+ import type { RuleClass, Rule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js";
2
2
  import type { HerbBackend } from "@herb-tools/core";
3
3
  export declare class Linter {
4
- private rules;
5
- private herb;
6
- private offenses;
4
+ protected rules: RuleClass[];
5
+ protected herb: HerbBackend;
6
+ protected offenses: LintOffense[];
7
7
  /**
8
8
  * Creates a new Linter instance.
9
9
  * @param herb - The Herb backend instance for parsing and lexing
@@ -14,16 +14,16 @@ export declare class Linter {
14
14
  * Returns the default set of rule classes used by the linter.
15
15
  * @returns Array of rule classes
16
16
  */
17
- private getDefaultRules;
17
+ protected getDefaultRules(): RuleClass[];
18
18
  getRuleCount(): number;
19
19
  /**
20
20
  * Type guard to check if a rule is a LexerRule
21
21
  */
22
- private isLexerRule;
22
+ protected isLexerRule(rule: Rule): rule is LexerRule;
23
23
  /**
24
24
  * Type guard to check if a rule is a SourceRule
25
25
  */
26
- private isSourceRule;
26
+ protected isSourceRule(rule: Rule): rule is SourceRule;
27
27
  /**
28
28
  * Lint source code using Parser/AST, Lexer, and Source rules.
29
29
  * @param source - The source code to lint
@@ -0,0 +1,7 @@
1
+ import { ParserRule } from "../types.js";
2
+ import type { LintOffense, LintContext } from "../types.js";
3
+ import type { ParseResult } from "@herb-tools/core";
4
+ export declare class HTMLNoEmptyAttributesRule extends ParserRule {
5
+ name: string;
6
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[];
7
+ }
@@ -0,0 +1,7 @@
1
+ import { ParserRule } from "../types.js";
2
+ import type { LintContext, LintOffense } from "../types.js";
3
+ import type { ParseResult } from "@herb-tools/core";
4
+ export declare class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
5
+ name: string;
6
+ check(result: ParseResult, context?: Partial<LintContext>): LintOffense[];
7
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./rule-utils.js";
1
2
  export * from "./erb-no-empty-tags.js";
2
3
  export * from "./erb-no-output-control-flow.js";
3
4
  export * from "./erb-no-silent-tag-in-attribute-name.js";
@@ -20,6 +21,7 @@ export * from "./html-no-aria-hidden-on-focusable.js";
20
21
  export * from "./html-no-block-inside-inline.js";
21
22
  export * from "./html-no-duplicate-attributes.js";
22
23
  export * from "./html-no-duplicate-ids.js";
24
+ export * from "./html-no-empty-attributes.js";
23
25
  export * from "./html-no-empty-headings.js";
24
26
  export * from "./html-no-nested-links.js";
25
27
  export * from "./html-no-positive-tab-index.js";
@@ -27,3 +29,4 @@ export * from "./html-no-self-closing.js";
27
29
  export * from "./html-no-title-attribute.js";
28
30
  export * from "./html-tag-name-lowercase.js";
29
31
  export * from "./svg-tag-name-capitalization.js";
32
+ export * from "./html-no-underscores-in-attribute-names.js";
@@ -1,6 +1,11 @@
1
1
  import { Visitor, Location } from "@herb-tools/core";
2
2
  import type { HTMLAttributeNode, HTMLAttributeValueNode, HTMLOpenTagNode, LexResult, Token, Node } from "@herb-tools/core";
3
+ import type * as Nodes from "@herb-tools/core";
3
4
  import type { LintOffense, LintSeverity, LintContext } from "../types.js";
5
+ export declare enum ControlFlowType {
6
+ CONDITIONAL = 0,
7
+ LOOP = 1
8
+ }
4
9
  /**
5
10
  * Base visitor class that provides common functionality for rule visitors
6
11
  */
@@ -18,6 +23,40 @@ export declare abstract class BaseRuleVisitor extends Visitor {
18
23
  */
19
24
  protected addOffense(message: string, location: Location, severity?: LintSeverity): void;
20
25
  }
26
+ /**
27
+ * Mixin that adds control flow tracking capabilities to rule visitors
28
+ * This allows rules to track state across different control flow structures
29
+ * like if/else branches, loops, etc.
30
+ *
31
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
32
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
33
+ */
34
+ export declare abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
35
+ protected isInControlFlow: boolean;
36
+ protected currentControlFlowType: ControlFlowType | null;
37
+ /**
38
+ * Handle visiting a control flow node with proper scope management
39
+ */
40
+ protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void;
41
+ /**
42
+ * Handle visiting a branch node (like else, when) with proper scope management
43
+ */
44
+ protected startNewBranch(visitChildren: () => void): void;
45
+ visitERBIfNode(node: Nodes.ERBIfNode): void;
46
+ visitERBUnlessNode(node: Nodes.ERBUnlessNode): void;
47
+ visitERBCaseNode(node: Nodes.ERBCaseNode): void;
48
+ visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void;
49
+ visitERBWhileNode(node: Nodes.ERBWhileNode): void;
50
+ visitERBForNode(node: Nodes.ERBForNode): void;
51
+ visitERBUntilNode(node: Nodes.ERBUntilNode): void;
52
+ visitERBBlockNode(node: Nodes.ERBBlockNode): void;
53
+ visitERBElseNode(node: Nodes.ERBElseNode): void;
54
+ visitERBWhenNode(node: Nodes.ERBWhenNode): void;
55
+ protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState;
56
+ protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void;
57
+ protected abstract onEnterBranch(): TBranchState;
58
+ protected abstract onExitBranch(stateToRestore: TBranchState): void;
59
+ }
21
60
  /**
22
61
  * Gets attributes from an HTMLOpenTagNode
23
62
  */
@@ -30,7 +69,7 @@ export declare function getTagName(node: HTMLOpenTagNode): string | null;
30
69
  * Gets the attribute name from an HTMLAttributeNode (lowercased)
31
70
  * Returns null if the attribute name contains dynamic content (ERB)
32
71
  */
33
- export declare function getAttributeName(attributeNode: HTMLAttributeNode): string | null;
72
+ export declare function getAttributeName(attributeNode: HTMLAttributeNode, lowercase?: boolean): string | null;
34
73
  /**
35
74
  * Checks if an attribute has a dynamic (ERB-containing) name
36
75
  */
@@ -114,12 +153,14 @@ export interface StaticAttributeStaticValueParams {
114
153
  attributeName: string;
115
154
  attributeValue: string;
116
155
  attributeNode: HTMLAttributeNode;
156
+ originalAttributeName: string;
117
157
  parentNode: HTMLOpenTagNode;
118
158
  }
119
159
  export interface StaticAttributeDynamicValueParams {
120
160
  attributeName: string;
121
161
  valueNodes: Node[];
122
162
  attributeNode: HTMLAttributeNode;
163
+ originalAttributeName: string;
123
164
  parentNode: HTMLOpenTagNode;
124
165
  combinedValue?: string | null;
125
166
  }
@@ -176,19 +217,19 @@ export declare abstract class AttributeVisitorMixin extends BaseRuleVisitor {
176
217
  /**
177
218
  * Static attribute name with static value: class="container"
178
219
  */
179
- protected checkStaticAttributeStaticValue(params: StaticAttributeStaticValueParams): void;
220
+ protected checkStaticAttributeStaticValue(_params: StaticAttributeStaticValueParams): void;
180
221
  /**
181
222
  * Static attribute name with dynamic value: class="<%= css_class %>"
182
223
  */
183
- protected checkStaticAttributeDynamicValue(params: StaticAttributeDynamicValueParams): void;
224
+ protected checkStaticAttributeDynamicValue(_params: StaticAttributeDynamicValueParams): void;
184
225
  /**
185
226
  * Dynamic attribute name with static value: data-<%= key %>="foo"
186
227
  */
187
- protected checkDynamicAttributeStaticValue(params: DynamicAttributeStaticValueParams): void;
228
+ protected checkDynamicAttributeStaticValue(_params: DynamicAttributeStaticValueParams): void;
188
229
  /**
189
230
  * Dynamic attribute name with dynamic value: data-<%= key %>="<%= value %>"
190
231
  */
191
- protected checkDynamicAttributeDynamicValue(params: DynamicAttributeDynamicValueParams): void;
232
+ protected checkDynamicAttributeDynamicValue(_params: DynamicAttributeDynamicValueParams): void;
192
233
  }
193
234
  /**
194
235
  * Checks if an attribute value is quoted
@@ -28,11 +28,13 @@ This page contains documentation for all Herb Linter rules.
28
28
  - [`html-no-block-inside-inline`](./html-no-block-inside-inline.md) - Prevents block-level elements inside inline elements
29
29
  - [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
30
30
  - [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
31
+ - [`html-no-empty-attributes`](./html-no-empty-attributes.md) - Attributes must not have empty values
31
32
  - [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
32
33
  - [`html-no-positive-tab-index`](./html-no-positive-tab-index.md) - Avoid positive `tabindex` values
33
34
  - [`html-no-self-closing`](./html-no-self-closing.md.md) - Disallow self closing tags
34
35
  - [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
35
36
  - [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
37
+ - [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
36
38
  - [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents
37
39
  - [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements
38
40
 
@@ -33,8 +33,6 @@ Omitting the `alt` attribute entirely leads to poor accessibility and can negati
33
33
 
34
34
  <img src="/avatar.jpg" alt> <!-- TODO -->
35
35
 
36
- <img src="/divider.png" alt=> <!-- TODO -->
37
-
38
36
  <%= image_tag image_path("logo.png") %> <!-- TODO -->
39
37
  ```
40
38
 
@@ -0,0 +1,77 @@
1
+ # Linter Rule: Attributes must not have empty values
2
+
3
+ **Rule:** `html-no-empty-attributes`
4
+
5
+ ## Description
6
+
7
+ Warn when certain restricted attributes are present but have an empty string as their value. These attributes are required to have meaningful values to function properly, and leaving them empty is typically either a mistake or unnecessary.
8
+
9
+ In most cases, if the value is not available, it's better to omit the attribute entirely.
10
+
11
+ ### Restricted attributes
12
+
13
+ - `id`
14
+ - `class`
15
+ - `name`
16
+ - `for`
17
+ - `src`
18
+ - `href`
19
+ - `title`
20
+ - `data`
21
+ - `role`
22
+ - `data-*`
23
+ - `aria-*`
24
+
25
+ ## Rationale
26
+
27
+ Many HTML attributes are only useful when they carry a value. Leaving these attributes empty can:
28
+
29
+ - Produce confusing or misleading markup (e.g., `id=""`, `class=""`)
30
+ - Create inaccessible or invalid HTML
31
+ - Interfere with CSS or JS selectors expecting meaningful values
32
+ - Indicate unused or unfinished logic in ERB
33
+
34
+ This rule helps ensure that required attributes are only added when they are populated.
35
+
36
+ ## Examples
37
+
38
+ ### ✅ Good
39
+
40
+ ```erb
41
+ <div id="header"></div>
42
+ <img src="/logo.png" alt="Company logo">
43
+ <input type="text" name="email">
44
+
45
+ <!-- Dynamic attributes with meaningful values -->
46
+ <div data-<%= key %>="<%= value %>" aria-<%= prop %>="<%= description %>">
47
+ Dynamic content
48
+ </div>
49
+
50
+ <!-- if no class should be set, omit it completely -->
51
+ <div>Plain div</div>
52
+ ```
53
+
54
+ ### 🚫 Bad
55
+
56
+ ```erb
57
+ <div id=""></div>
58
+ <img src="">
59
+ <input name="">
60
+
61
+ <div data-config="">Content</div>
62
+ <button aria-label="">×</button>
63
+
64
+ <div class="">Plain div</div>
65
+
66
+ <!-- Dynamic attribute names with empty static values -->
67
+ <div data-<%= key %>="" aria-<%= prop %>=" ">
68
+ Problematic dynamic attributes
69
+ </div>
70
+ ```
71
+
72
+ ## References
73
+
74
+ - [HTML Living Standard - Global attributes](https://html.spec.whatwg.org/multipage/dom.html#global-attributes)
75
+ - [WCAG 2.2 - Text Alternatives](https://www.w3.org/WAI/WCAG22/Understanding/text-alternatives.html)
76
+ - [MDN - HTML attribute reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes)
77
+ - [ARIA in HTML](https://www.w3.org/TR/html-aria/)
@@ -0,0 +1,45 @@
1
+ # Linter Rule: No underscores on attribute names
2
+
3
+ **Rule:** `html-no-underscores-in-attribute-names`
4
+
5
+ ## Description
6
+
7
+ ---
8
+
9
+ Warn when an HTML attribute name contains an underscore (`_`). According to the HTML specification, attribute names should use only lowercase letters, digits, hyphens (`-`), and colons (`:`) in specific namespaces (e.g., `xlink:href` in SVG). Underscores are not valid in standard HTML attribute names and may lead to unpredictable behavior or be ignored by browsers entirely.
10
+
11
+ ## Rationale
12
+
13
+ ---
14
+
15
+ Underscores in attribute names violate the HTML specification and are not supported in standard markup. Their use is almost always accidental (e.g., mistyping `data-attr_name` instead of `data-attr-name`) or stems from inconsistent naming conventions across backend or templating layers.
16
+
17
+ ## Examples
18
+
19
+ ---
20
+
21
+ ✅ Good
22
+
23
+ ```html
24
+ <div data-user-id="123"></div>
25
+
26
+ <img aria-label="Close">
27
+
28
+ <div data-<%= key %>-attribute="value"></div>
29
+ ```
30
+
31
+ 🚫 Bad
32
+
33
+ ```html
34
+ <div data_user_id="123"></div>
35
+
36
+ <img aria_label="Close">
37
+
38
+ <div data-<%= key %>_attribute="value"></div>
39
+ ```
40
+
41
+ ## References
42
+
43
+ ---
44
+
45
+ \-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/linter",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -21,6 +21,7 @@
21
21
  "build": "yarn clean && tsc -b && rollup -c",
22
22
  "watch": "tsc -b -w",
23
23
  "test": "vitest run",
24
+ "test:watch": "vitest --watch",
24
25
  "prepublishOnly": "yarn clean && yarn build && yarn test"
25
26
  },
26
27
  "exports": {
@@ -30,12 +31,18 @@
30
31
  "import": "./dist/index.js",
31
32
  "require": "./dist/index.cjs",
32
33
  "default": "./dist/index.js"
34
+ },
35
+ "./cli": {
36
+ "types": "./dist/types/src/cli.d.ts",
37
+ "require": "./dist/src/cli.js",
38
+ "default": "./dist/src/cli.js"
33
39
  }
34
40
  },
35
41
  "dependencies": {
36
- "@herb-tools/core": "0.6.0",
37
- "@herb-tools/highlighter": "0.6.0",
38
- "@herb-tools/node-wasm": "0.6.0",
42
+ "@herb-tools/core": "0.7.0",
43
+ "@herb-tools/highlighter": "0.7.0",
44
+ "@herb-tools/node-wasm": "0.7.0",
45
+ "@herb-tools/printer": "0.7.0",
39
46
  "glob": "^11.0.3"
40
47
  },
41
48
  "files": [
@@ -11,7 +11,7 @@ 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"
14
+ export type FormatOption = "simple" | "detailed" | "json"
15
15
 
16
16
  export interface ParsedArguments {
17
17
  pattern: string
@@ -20,6 +20,7 @@ export interface ParsedArguments {
20
20
  theme: ThemeInput
21
21
  wrapLines: boolean
22
22
  truncateLines: boolean
23
+ useGitHubActions: boolean
23
24
  }
24
25
 
25
26
  export class ArgumentParser {
@@ -34,10 +35,11 @@ export class ArgumentParser {
34
35
  Options:
35
36
  -h, --help show help
36
37
  -v, --version show version
37
- --format output format (simple|detailed|json|github) [default: detailed]
38
+ --format output format (simple|detailed|json) [default: detailed]
38
39
  --simple use simple output format (shortcut for --format simple)
39
40
  --json use JSON output format (shortcut for --format json)
40
- --github use GitHub Actions output format (shortcut for --format github)
41
+ --github enable GitHub Actions annotations (combines with --format)
42
+ --no-github disable GitHub Actions annotations (even in GitHub Actions environment)
41
43
  --theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}]
42
44
  --no-color disable colored output
43
45
  --no-timing hide timing information
@@ -55,6 +57,7 @@ export class ArgumentParser {
55
57
  simple: { type: "boolean" },
56
58
  json: { type: "boolean" },
57
59
  github: { type: "boolean" },
60
+ "no-github": { type: "boolean" },
58
61
  theme: { type: "string" },
59
62
  "no-color": { type: "boolean" },
60
63
  "no-timing": { type: "boolean" },
@@ -75,8 +78,10 @@ export class ArgumentParser {
75
78
  process.exit(0)
76
79
  }
77
80
 
81
+ const isGitHubActions = process.env.GITHUB_ACTIONS === "true"
82
+
78
83
  let formatOption: FormatOption = "detailed"
79
- if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json" || values.format === "github")) {
84
+ if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json")) {
80
85
  formatOption = values.format
81
86
  }
82
87
 
@@ -88,8 +93,11 @@ export class ArgumentParser {
88
93
  formatOption = "json"
89
94
  }
90
95
 
91
- if (values.github) {
92
- formatOption = "github"
96
+ const useGitHubActions = (values.github || isGitHubActions) && !values["no-github"]
97
+
98
+ if (useGitHubActions && formatOption === "json") {
99
+ console.error("Error: --github cannot be used with --json format. JSON format is already structured for programmatic consumption.")
100
+ process.exit(1)
93
101
  }
94
102
 
95
103
  if (values["no-color"]) {
@@ -114,7 +122,7 @@ export class ArgumentParser {
114
122
  const theme = values.theme || DEFAULT_THEME
115
123
  const pattern = this.getFilePattern(positionals)
116
124
 
117
- return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines }
125
+ return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions }
118
126
  }
119
127
 
120
128
  private getFilePattern(positionals: string[]): string {
@@ -12,6 +12,11 @@ export interface ProcessedFile {
12
12
  content: string
13
13
  }
14
14
 
15
+ export interface ProcessingContext {
16
+ projectPath?: string
17
+ pattern?: string
18
+ }
19
+
15
20
  export interface ProcessingResult {
16
21
  totalErrors: number
17
22
  totalWarnings: number
@@ -19,12 +24,13 @@ export interface ProcessingResult {
19
24
  ruleCount: number
20
25
  allOffenses: ProcessedFile[]
21
26
  ruleOffenses: Map<string, { count: number, files: Set<string> }>
27
+ context?: ProcessingContext
22
28
  }
23
29
 
24
30
  export class FileProcessor {
25
31
  private linter: Linter | null = null
26
32
 
27
- async processFiles(files: string[], formatOption: FormatOption = 'detailed'): Promise<ProcessingResult> {
33
+ async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {
28
34
  let totalErrors = 0
29
35
  let totalWarnings = 0
30
36
  let filesWithOffenses = 0
@@ -33,13 +39,12 @@ export class FileProcessor {
33
39
  const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()
34
40
 
35
41
  for (const filename of files) {
36
- const filePath = resolve(filename)
42
+ const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename)
37
43
  const content = readFileSync(filePath, "utf-8")
38
-
39
44
  const parseResult = Herb.parse(content)
40
45
 
41
46
  if (parseResult.errors.length > 0) {
42
- if (formatOption !== 'json' && formatOption !== 'github') {
47
+ if (formatOption !== 'json') {
43
48
  console.error(`${colorize(filename, "cyan")} - ${colorize("Parse errors:", "brightRed")}`)
44
49
 
45
50
  for (const error of parseResult.errors) {
@@ -47,7 +52,6 @@ export class FileProcessor {
47
52
  }
48
53
  }
49
54
 
50
- // Add parse errors to offenses for JSON output
51
55
  for (const error of parseResult.errors) {
52
56
  allOffenses.push({ filename, offense: error, content })
53
57
  }
@@ -68,7 +72,7 @@ export class FileProcessor {
68
72
  }
69
73
 
70
74
  if (lintResult.offenses.length === 0) {
71
- if (files.length === 1 && formatOption !== 'json' && formatOption !== 'github') {
75
+ if (files.length === 1 && formatOption !== 'json') {
72
76
  console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`)
73
77
  }
74
78
  } else {
@@ -87,6 +91,6 @@ export class FileProcessor {
87
91
  }
88
92
  }
89
93
 
90
- return { totalErrors, totalWarnings, filesWithOffenses, ruleCount, allOffenses, ruleOffenses }
94
+ return { totalErrors, totalWarnings, filesWithOffenses, ruleCount, allOffenses, ruleOffenses, context }
91
95
  }
92
96
  }
@@ -1,6 +1,7 @@
1
1
  import { colorize, Highlighter, type ThemeInput, DEFAULT_THEME } from "@herb-tools/highlighter"
2
2
 
3
3
  import { BaseFormatter } from "./base-formatter.js"
4
+ import { LineWrapper } from "@herb-tools/highlighter"
4
5
 
5
6
  import type { Diagnostic } from "@herb-tools/core"
6
7
  import type { ProcessedFile } from "../file-processor.js"
@@ -27,13 +28,12 @@ export class DetailedFormatter extends BaseFormatter {
27
28
  }
28
29
 
29
30
  if (isSingleFile) {
30
- // For single file, use inline diagnostics with syntax highlighting
31
31
  const { filename, content } = allOffenses[0]
32
32
  const diagnostics = allOffenses.map(item => item.offense)
33
33
 
34
34
  const highlighted = this.highlighter.highlight(filename, content, {
35
35
  diagnostics: diagnostics,
36
- splitDiagnostics: true, // Use split mode to show each diagnostic separately
36
+ splitDiagnostics: true,
37
37
  contextLines: 2,
38
38
  wrapLines: this.wrapLines,
39
39
  truncateLines: this.truncateLines
@@ -41,19 +41,18 @@ export class DetailedFormatter extends BaseFormatter {
41
41
 
42
42
  console.log(`\n${highlighted}`)
43
43
  } else {
44
- // For multiple files, show individual diagnostics with syntax highlighting
45
44
  const totalMessageCount = allOffenses.length
46
45
 
47
46
  for (let i = 0; i < allOffenses.length; i++) {
48
47
  const { filename, offense, content } = allOffenses[i]
49
- const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, {
50
- contextLines: 2,
48
+ const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, {
49
+ contextLines: 2,
51
50
  wrapLines: this.wrapLines,
52
51
  truncateLines: this.truncateLines
53
52
  })
54
53
  console.log(`\n${formatted}`)
55
54
 
56
- const width = process.stdout.columns || 80
55
+ const width = LineWrapper.getTerminalWidth()
57
56
  const progressText = `[${i + 1}/${totalMessageCount}]`
58
57
  const rightPadding = 16
59
58
  const separatorLength = Math.max(0, width - progressText.length - 1 - rightPadding)
@@ -68,7 +67,6 @@ export class DetailedFormatter extends BaseFormatter {
68
67
  }
69
68
 
70
69
  formatFile(_filename: string, _offenses: Diagnostic[]): void {
71
- // Not used in detailed formatter
72
70
  throw new Error("formatFile is not implemented for DetailedFormatter")
73
71
  }
74
72
  }