@herb-tools/linter 0.8.6 → 0.8.8

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 (82) hide show
  1. package/README.md +54 -2
  2. package/dist/herb-lint.js +17157 -31275
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +473 -2113
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +468 -2115
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +6868 -11350
  9. package/dist/loader.cjs.map +1 -1
  10. package/dist/loader.js +6862 -11351
  11. package/dist/loader.js.map +1 -1
  12. package/dist/package.json +9 -8
  13. package/dist/src/cli/argument-parser.js +18 -2
  14. package/dist/src/cli/argument-parser.js.map +1 -1
  15. package/dist/src/cli/file-processor.js +1 -1
  16. package/dist/src/cli/file-processor.js.map +1 -1
  17. package/dist/src/cli.js +25 -10
  18. package/dist/src/cli.js.map +1 -1
  19. package/dist/src/custom-rule-loader.js +2 -2
  20. package/dist/src/custom-rule-loader.js.map +1 -1
  21. package/dist/src/linter.js +16 -3
  22. package/dist/src/linter.js.map +1 -1
  23. package/dist/src/rules/erb-strict-locals-comment-syntax.js +206 -0
  24. package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +1 -0
  25. package/dist/src/rules/erb-strict-locals-required.js +38 -0
  26. package/dist/src/rules/erb-strict-locals-required.js.map +1 -0
  27. package/dist/src/rules/file-utils.js +21 -0
  28. package/dist/src/rules/file-utils.js.map +1 -0
  29. package/dist/src/rules/html-head-only-elements.js +2 -0
  30. package/dist/src/rules/html-head-only-elements.js.map +1 -1
  31. package/dist/src/rules/html-no-duplicate-attributes.js +91 -21
  32. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  33. package/dist/src/rules/html-no-empty-headings.js +22 -36
  34. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  35. package/dist/src/rules/index.js +4 -0
  36. package/dist/src/rules/index.js.map +1 -1
  37. package/dist/src/rules/string-utils.js +72 -0
  38. package/dist/src/rules/string-utils.js.map +1 -0
  39. package/dist/src/rules.js +4 -0
  40. package/dist/src/rules.js.map +1 -1
  41. package/dist/src/types.js +6 -0
  42. package/dist/src/types.js.map +1 -1
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/dist/types/cli/argument-parser.d.ts +3 -0
  45. package/dist/types/cli/file-processor.d.ts +1 -0
  46. package/dist/types/cli.d.ts +1 -1
  47. package/dist/types/linter.d.ts +5 -1
  48. package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  49. package/dist/types/rules/erb-strict-locals-required.d.ts +9 -0
  50. package/dist/types/rules/file-utils.d.ts +13 -0
  51. package/dist/types/rules/index.d.ts +4 -0
  52. package/dist/types/rules/string-utils.d.ts +15 -0
  53. package/dist/types/src/cli/argument-parser.d.ts +3 -0
  54. package/dist/types/src/cli/file-processor.d.ts +1 -0
  55. package/dist/types/src/cli.d.ts +1 -1
  56. package/dist/types/src/linter.d.ts +5 -1
  57. package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +9 -0
  58. package/dist/types/src/rules/erb-strict-locals-required.d.ts +9 -0
  59. package/dist/types/src/rules/file-utils.d.ts +13 -0
  60. package/dist/types/src/rules/index.d.ts +4 -0
  61. package/dist/types/src/rules/string-utils.d.ts +15 -0
  62. package/dist/types/src/types.d.ts +6 -0
  63. package/dist/types/types.d.ts +6 -0
  64. package/docs/rules/README.md +1 -0
  65. package/docs/rules/erb-strict-locals-comment-syntax.md +153 -0
  66. package/docs/rules/erb-strict-locals-required.md +107 -0
  67. package/package.json +9 -8
  68. package/src/cli/argument-parser.ts +21 -2
  69. package/src/cli/file-processor.ts +2 -1
  70. package/src/cli.ts +34 -11
  71. package/src/custom-rule-loader.ts +2 -2
  72. package/src/linter.ts +19 -3
  73. package/src/rules/erb-strict-locals-comment-syntax.ts +274 -0
  74. package/src/rules/erb-strict-locals-required.ts +52 -0
  75. package/src/rules/file-utils.ts +23 -0
  76. package/src/rules/html-head-only-elements.ts +1 -0
  77. package/src/rules/html-no-duplicate-attributes.ts +141 -26
  78. package/src/rules/html-no-empty-headings.ts +21 -44
  79. package/src/rules/index.ts +4 -0
  80. package/src/rules/string-utils.ts +72 -0
  81. package/src/rules.ts +4 -0
  82. package/src/types.ts +6 -0
@@ -123,7 +123,11 @@ export declare class Linter {
123
123
  * @param source - The source code to fix
124
124
  * @param context - Optional context for linting (e.g., fileName)
125
125
  * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
126
+ * @param options - Options for autofix behavior
127
+ * @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
126
128
  * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
127
129
  */
128
- autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[]): AutofixResult;
130
+ autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: {
131
+ includeUnsafe?: boolean;
132
+ }): AutofixResult;
129
133
  }
@@ -0,0 +1,9 @@
1
+ import { ParserRule } from "../types.js";
2
+ import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js";
3
+ import type { ParseResult } from "@herb-tools/core";
4
+ export declare const STRICT_LOCALS_PATTERN: RegExp;
5
+ export declare class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
6
+ name: string;
7
+ get defaultConfig(): FullRuleConfig;
8
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[];
9
+ }
@@ -0,0 +1,9 @@
1
+ import { SourceRule } from "../types.js";
2
+ import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js";
3
+ export declare class ERBStrictLocalsRequiredRule extends SourceRule {
4
+ static unsafeAutocorrectable: boolean;
5
+ name: string;
6
+ get defaultConfig(): FullRuleConfig;
7
+ check(source: string, context?: Partial<LintContext>): UnboundLintOffense[];
8
+ autofix(_offense: LintOffense, source: string, _context?: Partial<LintContext>): string | null;
9
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * File path and naming utilities for linter rules
3
+ */
4
+ /**
5
+ * Extracts the basename (filename) from a file path
6
+ * Works with both forward slashes and backslashes
7
+ */
8
+ export declare function getBasename(filePath: string): string;
9
+ /**
10
+ * Checks if a file is a Rails partial (filename starts with `_`)
11
+ * Returns null if fileName is undefined (unknown context)
12
+ */
13
+ export declare function isPartialFile(fileName: string | undefined): boolean | null;
@@ -1,4 +1,6 @@
1
1
  export * from "./rule-utils.js";
2
+ export * from "./file-utils.js";
3
+ export * from "./string-utils.js";
2
4
  export * from "./herb-disable-comment-base.js";
3
5
  export * from "./erb-comment-syntax.js";
4
6
  export * from "./erb-no-case-node-children.js";
@@ -11,6 +13,8 @@ export * from "./erb-prefer-image-tag-helper.js";
11
13
  export * from "./erb-require-trailing-newline.js";
12
14
  export * from "./erb-require-whitespace-inside-tags.js";
13
15
  export * from "./erb-right-trim.js";
16
+ export * from "./erb-strict-locals-comment-syntax.js";
17
+ export * from "./erb-strict-locals-required.js";
14
18
  export * from "./herb-disable-comment-valid-rule-name.js";
15
19
  export * from "./herb-disable-comment-no-redundant-all.js";
16
20
  export * from "./herb-disable-comment-no-duplicate-rules.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Checks if parentheses in a string are balanced
3
+ * Returns false if there are more closing parens than opening at any point
4
+ */
5
+ export declare function hasBalancedParentheses(content: string): boolean;
6
+ /**
7
+ * Splits a string by commas at the top level only
8
+ * Respects nested parentheses, brackets, braces, and strings
9
+ *
10
+ * @example
11
+ * splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
12
+ * splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
13
+ * splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
14
+ */
15
+ export declare function splitByTopLevelComma(str: string): string[];
@@ -1,4 +1,5 @@
1
1
  import type { ThemeInput } from "@herb-tools/highlighter";
2
+ import type { DiagnosticSeverity } from "@herb-tools/core";
2
3
  export type FormatOption = "simple" | "detailed" | "json";
3
4
  export interface ParsedArguments {
4
5
  patterns: string[];
@@ -10,10 +11,12 @@ export interface ParsedArguments {
10
11
  truncateLines: boolean;
11
12
  useGitHubActions: boolean;
12
13
  fix: boolean;
14
+ fixUnsafe: boolean;
13
15
  ignoreDisableComments: boolean;
14
16
  force: boolean;
15
17
  init: boolean;
16
18
  loadCustomRules: boolean;
19
+ failLevel?: DiagnosticSeverity;
17
20
  }
18
21
  export declare class ArgumentParser {
19
22
  private readonly usage;
@@ -12,6 +12,7 @@ export interface ProcessingContext {
12
12
  projectPath?: string;
13
13
  pattern?: string;
14
14
  fix?: boolean;
15
+ fixUnsafe?: boolean;
15
16
  ignoreDisableComments?: boolean;
16
17
  linterConfig?: HerbConfigOptions['linter'];
17
18
  config?: Config;
@@ -17,7 +17,7 @@ export declare class CLI {
17
17
  showTiming: boolean;
18
18
  }): void;
19
19
  protected determineProjectPath(patterns: string[]): void;
20
- protected adjustPattern(pattern: string | undefined, configGlobPattern: string): string;
20
+ protected adjustPattern(pattern: string | undefined, configGlobPatterns: string[]): string;
21
21
  protected resolvePatternToFiles(pattern: string, config: Config, force: boolean): Promise<{
22
22
  files: string[];
23
23
  explicitFile: string | undefined;
@@ -123,7 +123,11 @@ export declare class Linter {
123
123
  * @param source - The source code to fix
124
124
  * @param context - Optional context for linting (e.g., fileName)
125
125
  * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
126
+ * @param options - Options for autofix behavior
127
+ * @param options.includeUnsafe - If true, also apply unsafe fixes (rules with unsafeAutocorrectable = true)
126
128
  * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
127
129
  */
128
- autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[]): AutofixResult;
130
+ autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: {
131
+ includeUnsafe?: boolean;
132
+ }): AutofixResult;
129
133
  }
@@ -0,0 +1,9 @@
1
+ import { ParserRule } from "../types.js";
2
+ import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js";
3
+ import type { ParseResult } from "@herb-tools/core";
4
+ export declare const STRICT_LOCALS_PATTERN: RegExp;
5
+ export declare class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
6
+ name: string;
7
+ get defaultConfig(): FullRuleConfig;
8
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[];
9
+ }
@@ -0,0 +1,9 @@
1
+ import { SourceRule } from "../types.js";
2
+ import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js";
3
+ export declare class ERBStrictLocalsRequiredRule extends SourceRule {
4
+ static unsafeAutocorrectable: boolean;
5
+ name: string;
6
+ get defaultConfig(): FullRuleConfig;
7
+ check(source: string, context?: Partial<LintContext>): UnboundLintOffense[];
8
+ autofix(_offense: LintOffense, source: string, _context?: Partial<LintContext>): string | null;
9
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * File path and naming utilities for linter rules
3
+ */
4
+ /**
5
+ * Extracts the basename (filename) from a file path
6
+ * Works with both forward slashes and backslashes
7
+ */
8
+ export declare function getBasename(filePath: string): string;
9
+ /**
10
+ * Checks if a file is a Rails partial (filename starts with `_`)
11
+ * Returns null if fileName is undefined (unknown context)
12
+ */
13
+ export declare function isPartialFile(fileName: string | undefined): boolean | null;
@@ -1,4 +1,6 @@
1
1
  export * from "./rule-utils.js";
2
+ export * from "./file-utils.js";
3
+ export * from "./string-utils.js";
2
4
  export * from "./herb-disable-comment-base.js";
3
5
  export * from "./erb-comment-syntax.js";
4
6
  export * from "./erb-no-case-node-children.js";
@@ -11,6 +13,8 @@ export * from "./erb-prefer-image-tag-helper.js";
11
13
  export * from "./erb-require-trailing-newline.js";
12
14
  export * from "./erb-require-whitespace-inside-tags.js";
13
15
  export * from "./erb-right-trim.js";
16
+ export * from "./erb-strict-locals-comment-syntax.js";
17
+ export * from "./erb-strict-locals-required.js";
14
18
  export * from "./herb-disable-comment-valid-rule-name.js";
15
19
  export * from "./herb-disable-comment-no-redundant-all.js";
16
20
  export * from "./herb-disable-comment-no-duplicate-rules.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Checks if parentheses in a string are balanced
3
+ * Returns false if there are more closing parens than opening at any point
4
+ */
5
+ export declare function hasBalancedParentheses(content: string): boolean;
6
+ /**
7
+ * Splits a string by commas at the top level only
8
+ * Respects nested parentheses, brackets, braces, and strings
9
+ *
10
+ * @example
11
+ * splitByTopLevelComma("a, b, c") // ["a", " b", " c"]
12
+ * splitByTopLevelComma("a, (b, c), d") // ["a", " (b, c)", " d"]
13
+ * splitByTopLevelComma('a, "b, c", d') // ["a", ' "b, c"', " d"]
14
+ */
15
+ export declare function splitByTopLevelComma(str: string): string[];
@@ -68,6 +68,8 @@ export declare abstract class ParserRule<TAutofixContext extends BaseAutofixCont
68
68
  static type: "parser";
69
69
  /** Indicates whether this rule supports autofix. Defaults to false. */
70
70
  static autocorrectable: boolean;
71
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
72
+ static unsafeAutocorrectable: boolean;
71
73
  abstract name: string;
72
74
  get defaultConfig(): FullRuleConfig;
73
75
  abstract check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -96,6 +98,8 @@ export declare abstract class LexerRule<TAutofixContext extends BaseAutofixConte
96
98
  static type: "lexer";
97
99
  /** Indicates whether this rule supports autofix. Defaults to false. */
98
100
  static autocorrectable: boolean;
101
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
102
+ static unsafeAutocorrectable: boolean;
99
103
  abstract name: string;
100
104
  get defaultConfig(): FullRuleConfig;
101
105
  abstract check(lexResult: LexResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -139,6 +143,8 @@ export declare abstract class SourceRule<TAutofixContext extends BaseAutofixCont
139
143
  static type: "source";
140
144
  /** Indicates whether this rule supports autofix. Defaults to false. */
141
145
  static autocorrectable: boolean;
146
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
147
+ static unsafeAutocorrectable: boolean;
142
148
  abstract name: string;
143
149
  get defaultConfig(): FullRuleConfig;
144
150
  abstract check(source: string, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -68,6 +68,8 @@ export declare abstract class ParserRule<TAutofixContext extends BaseAutofixCont
68
68
  static type: "parser";
69
69
  /** Indicates whether this rule supports autofix. Defaults to false. */
70
70
  static autocorrectable: boolean;
71
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
72
+ static unsafeAutocorrectable: boolean;
71
73
  abstract name: string;
72
74
  get defaultConfig(): FullRuleConfig;
73
75
  abstract check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -96,6 +98,8 @@ export declare abstract class LexerRule<TAutofixContext extends BaseAutofixConte
96
98
  static type: "lexer";
97
99
  /** Indicates whether this rule supports autofix. Defaults to false. */
98
100
  static autocorrectable: boolean;
101
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
102
+ static unsafeAutocorrectable: boolean;
99
103
  abstract name: string;
100
104
  get defaultConfig(): FullRuleConfig;
101
105
  abstract check(lexResult: LexResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -139,6 +143,8 @@ export declare abstract class SourceRule<TAutofixContext extends BaseAutofixCont
139
143
  static type: "source";
140
144
  /** Indicates whether this rule supports autofix. Defaults to false. */
141
145
  static autocorrectable: boolean;
146
+ /** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
147
+ static unsafeAutocorrectable: boolean;
142
148
  abstract name: string;
143
149
  get defaultConfig(): FullRuleConfig;
144
150
  abstract check(source: string, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[];
@@ -15,6 +15,7 @@ This page contains documentation for all Herb Linter rules.
15
15
  - [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags
16
16
  - [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character.
17
17
  - [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax.
18
+ - [`erb-strict-locals-comment-syntax`](./erb-strict-locals-comment-syntax.md) - Enforce strict locals comment syntax.
18
19
  - [`herb-disable-comment-malformed`](./herb-disable-comment-malformed.md) - Detect malformed `herb:disable` comments.
19
20
  - [`herb-disable-comment-missing-rules`](./herb-disable-comment-missing-rules.md) - Require rule names in `herb:disable` comments.
20
21
  - [`herb-disable-comment-no-duplicate-rules`](./herb-disable-comment-no-duplicate-rules.md) - Disallow duplicate rule names in `herb:disable` comments.
@@ -0,0 +1,153 @@
1
+ # Linter Rule: Enforce strict locals comment syntax
2
+
3
+ **Rule:** `erb-strict-locals-comment-syntax`
4
+
5
+ ## Description
6
+
7
+ Ensures that strict locals comments use the exact `locals: ( ... )` syntax so they are properly recognized by Rails and tooling. Also validates that only keyword arguments are used (no positional, block, or splat arguments).
8
+
9
+ ## Rationale
10
+
11
+ Strict locals comments declare which locals are expected in a template. Misspellings or malformed syntax silently disable the declaration, leading to confusing runtime errors when required locals are missing.
12
+
13
+ Additionally, Rails only supports keyword arguments in strict locals declarations. Positional, block, and splat arguments will raise an `ActionView::Error` at render-time.
14
+
15
+ This rule catches invalid comment forms and argument types early during development.
16
+
17
+ ## Examples
18
+
19
+ ### ✅ Good
20
+
21
+ Required keyword argument:
22
+
23
+ ```erb
24
+ <%# locals: (user:) %>
25
+ ```
26
+
27
+ Keyword argument with default value:
28
+
29
+ ```erb
30
+ <%# locals: (user:, admin: false) %>
31
+ ```
32
+
33
+ Complex default values:
34
+
35
+ ```erb
36
+ <%# locals: (items: [], config: {}) %>
37
+ ```
38
+
39
+ No locals (empty):
40
+
41
+ ```erb
42
+ <%# locals: () %>
43
+ ```
44
+
45
+ Double-splat for optional keyword arguments:
46
+
47
+ ```erb
48
+ <%# locals: (message: "Hello", **attributes) %>
49
+ ```
50
+
51
+ ### 🚫 Bad
52
+
53
+ #### Wrong comment syntax
54
+
55
+ Missing colon after `locals`:
56
+
57
+ ```erb
58
+ <%# locals() %>
59
+ ```
60
+
61
+ Singular `local` instead of `locals`:
62
+
63
+ ```erb
64
+ <%# local: (user:) %>
65
+ ```
66
+
67
+ Missing colon before parentheses:
68
+
69
+ ```erb
70
+ <%# locals (user:) %>
71
+ ```
72
+
73
+ Missing parentheses around parameters:
74
+
75
+ ```erb
76
+ <%# locals: user %>
77
+ ```
78
+
79
+ Empty `locals:` without parentheses:
80
+
81
+ ```erb
82
+ <%# locals: %>
83
+ ```
84
+
85
+ Unbalanced parentheses:
86
+
87
+ ```erb
88
+ <%# locals: (user: %>
89
+ ```
90
+
91
+ #### Wrong tag type (must use ERB comment tag)
92
+
93
+ Ruby comment in execution tag:
94
+
95
+ ```erb
96
+ <% # locals: (user:) %>
97
+ ```
98
+
99
+ #### Unsupported argument types
100
+
101
+ Positional argument (use `user:` instead):
102
+
103
+ ```erb
104
+ <%# locals: (user) %>
105
+ ```
106
+
107
+ Block argument:
108
+
109
+ ```erb
110
+ <%# locals: (&block) %>
111
+ ```
112
+
113
+ Single splat argument:
114
+
115
+ ```erb
116
+ <%# locals: (*args) %>
117
+ ```
118
+
119
+ Note: Double-splat (`**attributes`) IS supported for optional keyword arguments.
120
+
121
+ #### Invalid Ruby syntax
122
+
123
+ Trailing comma:
124
+
125
+ ```erb
126
+ <%# locals: (user:,) %>
127
+ ```
128
+
129
+ Leading comma:
130
+
131
+ ```erb
132
+ <%# locals: (, user:) %>
133
+ ```
134
+
135
+ Double comma:
136
+
137
+ ```erb
138
+ <%# locals: (user:,, admin:) %>
139
+ ```
140
+
141
+ #### Duplicate declarations
142
+
143
+ Only one `locals:` comment is allowed per partial:
144
+
145
+ ```erb
146
+ <%# locals: (user:) %>
147
+ <p>Content</p>
148
+ <%# locals: (admin:) %>
149
+ ```
150
+
151
+ ## References
152
+
153
+ - [Action View - Strict Locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)
@@ -0,0 +1,107 @@
1
+ # Linter Rule: Require strict locals in Rails partials
2
+
3
+ **Rule:** `erb-strict-locals-required`
4
+
5
+ **Default:** Disabled (opt-in)
6
+
7
+ ## Description
8
+
9
+ Requires that every Rails partial template includes a strict locals declaration comment using the supported syntax:
10
+
11
+ ```erb
12
+ <%# locals: () %>
13
+ ```
14
+
15
+ A partial is any template whose filename begins with an underscore (e.g. `_card.html.erb`).
16
+
17
+ ## Rationale
18
+
19
+ Partials often rely on implicit locals, which makes them harder to understand, refactor, and lint. Requiring strict locals:
20
+
21
+ - Documents the partial's public API at the top of the file
22
+ - Improves readability and onboarding
23
+ - Enables better static analysis (unknown locals, missing locals, unused locals)
24
+ - Reduces runtime surprises when locals are renamed or removed
25
+
26
+ This rule encourages partials to be explicit about what they expect. Partials that intentionally accept no locals should still declare an explicit empty signature.
27
+
28
+ ## Configuration
29
+
30
+ This rule is disabled by default. To enable it, add to your [`.herb.yml`](/configuration):
31
+
32
+ ```yaml [.herb.yml]
33
+ linter:
34
+ rules:
35
+ erb-strict-locals-required:
36
+ enabled: true
37
+ ```
38
+
39
+ ## Autofix
40
+
41
+ This rule supports **unsafe autofix** via `--fix-unsafely`. When applied, it inserts an empty strict locals declaration at the top of the file:
42
+
43
+ ```erb
44
+ <%# locals: () %>
45
+ ```
46
+
47
+ This is considered "unsafe" because:
48
+ - It changes the partial's behavior (strict mode will now error on undeclared locals)
49
+ - You may need to manually add the actual local variables your partial uses
50
+
51
+ To apply the autofix:
52
+
53
+ ```bash
54
+ herb-lint --fix-unsafely _partial.html.erb
55
+ ```
56
+
57
+ After the autofix runs, review the file and update the locals declaration to include any variables your partial expects.
58
+
59
+ ## Examples
60
+
61
+ ### ✅ Good
62
+
63
+ Partial with required keyword argument:
64
+
65
+ ```erb
66
+ <%# locals: (user:) %>
67
+
68
+ <div class="user-card">
69
+ <%= user.name %>
70
+ </div>
71
+ ```
72
+
73
+ Partial with keyword argument and default:
74
+
75
+ ```erb [app/views/users/_card.html.erb]
76
+ <%# locals: (user:, admin: false) %>
77
+
78
+ <div class="user-card">
79
+ <%= user.name %>
80
+
81
+ <% if admin %>
82
+ <span class="badge">Admin</span>
83
+ <% end %>
84
+ </div>
85
+ ```
86
+
87
+ Partial with no locals (empty declaration):
88
+
89
+ ```erb [app/views/pages/_content.html.erb]
90
+ <%# locals: () %>
91
+
92
+ <p>Static content only</p>
93
+ ```
94
+
95
+ ### 🚫 Bad
96
+
97
+ Partial without strict locals declaration:
98
+
99
+ ```erb [app/views/users/_card.html.erb]
100
+ <div class="user-card">
101
+ <%= user.name %>
102
+ </div>
103
+ ```
104
+
105
+ ## References
106
+
107
+ - [Action View - Strict Locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/linter",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
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",
@@ -45,13 +45,14 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@herb-tools/config": "0.8.6",
49
- "@herb-tools/core": "0.8.6",
50
- "@herb-tools/highlighter": "0.8.6",
51
- "@herb-tools/node-wasm": "0.8.6",
52
- "@herb-tools/printer": "0.8.6",
53
- "@herb-tools/rewriter": "0.8.6",
54
- "glob": "^13.0.0"
48
+ "@herb-tools/config": "0.8.8",
49
+ "@herb-tools/core": "0.8.8",
50
+ "@herb-tools/highlighter": "0.8.8",
51
+ "@herb-tools/node-wasm": "0.8.8",
52
+ "@herb-tools/printer": "0.8.8",
53
+ "@herb-tools/rewriter": "0.8.8",
54
+ "picomatch": "^4.0.2",
55
+ "tinyglobby": "^0.2.15"
55
56
  },
56
57
  "files": [
57
58
  "package.json",
@@ -5,6 +5,7 @@ import { Herb } from "@herb-tools/node-wasm"
5
5
 
6
6
  import { THEME_NAMES, DEFAULT_THEME } from "@herb-tools/highlighter"
7
7
  import type { ThemeInput } from "@herb-tools/highlighter"
8
+ import type { DiagnosticSeverity } from "@herb-tools/core"
8
9
 
9
10
  import { name, version, dependencies } from "../../package.json"
10
11
 
@@ -20,10 +21,12 @@ export interface ParsedArguments {
20
21
  truncateLines: boolean
21
22
  useGitHubActions: boolean
22
23
  fix: boolean
24
+ fixUnsafe: boolean
23
25
  ignoreDisableComments: boolean
24
26
  force: boolean
25
27
  init: boolean
26
28
  loadCustomRules: boolean
29
+ failLevel?: DiagnosticSeverity
27
30
  }
28
31
 
29
32
  export class ArgumentParser {
@@ -41,7 +44,9 @@ export class ArgumentParser {
41
44
  -c, --config-file <path> explicitly specify path to .herb.yml config file
42
45
  --force force linting even if disabled in .herb.yml
43
46
  --fix automatically fix auto-correctable offenses
47
+ --fix-unsafely also apply unsafe auto-fixes (implies --fix)
44
48
  --ignore-disable-comments report offenses even when suppressed with <%# herb:disable %> comments
49
+ --fail-level <severity> exit with error code when diagnostics of this severity or higher are present (error|warning|info|hint) [default: error]
45
50
  --format output format (simple|detailed|json) [default: detailed]
46
51
  --simple use simple output format (shortcut for --format simple)
47
52
  --json use JSON output format (shortcut for --format json)
@@ -65,7 +70,9 @@ export class ArgumentParser {
65
70
  "config-file": { type: "string", short: "c" },
66
71
  force: { type: "boolean" },
67
72
  fix: { type: "boolean" },
73
+ "fix-unsafely": { type: "boolean" },
68
74
  "ignore-disable-comments": { type: "boolean" },
75
+ "fail-level": { type: "string" },
69
76
  format: { type: "string" },
70
77
  simple: { type: "boolean" },
71
78
  json: { type: "boolean" },
@@ -137,14 +144,26 @@ export class ArgumentParser {
137
144
 
138
145
  const theme = values.theme || DEFAULT_THEME
139
146
  const patterns = this.getFilePatterns(positionals)
140
- const fix = values.fix || false
147
+ const fixUnsafe = values["fix-unsafely"] || false
148
+ const fix = values.fix || fixUnsafe // --fix-unsafely implies --fix
141
149
  const force = !!values.force
142
150
  const ignoreDisableComments = values["ignore-disable-comments"] || false
143
151
  const configFile = values["config-file"]
144
152
  const init = values.init || false
145
153
  const loadCustomRules = !values["no-custom-rules"]
146
154
 
147
- return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init, loadCustomRules }
155
+ let failLevel: DiagnosticSeverity | undefined
156
+ if (values["fail-level"]) {
157
+ const level = values["fail-level"]
158
+ if (level === "error" || level === "warning" || level === "info" || level === "hint") {
159
+ failLevel = level
160
+ } else {
161
+ console.error(`Error: Invalid --fail-level value "${level}". Must be one of: error, warning, info, hint`)
162
+ process.exit(1)
163
+ }
164
+ }
165
+
166
+ return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel }
148
167
  }
149
168
 
150
169
  private getFilePatterns(positionals: string[]): string[] {
@@ -22,6 +22,7 @@ export interface ProcessingContext {
22
22
  projectPath?: string
23
23
  pattern?: string
24
24
  fix?: boolean
25
+ fixUnsafe?: boolean
25
26
  ignoreDisableComments?: boolean
26
27
  linterConfig?: HerbConfigOptions['linter']
27
28
  config?: Config
@@ -138,7 +139,7 @@ export class FileProcessor {
138
139
  const autofixResult = this.linter.autofix(content, {
139
140
  fileName: filename,
140
141
  ignoreDisableComments: context?.ignoreDisableComments
141
- })
142
+ }, undefined, { includeUnsafe: context?.fixUnsafe })
142
143
 
143
144
  if (autofixResult.fixed.length > 0) {
144
145
  writeFileSync(filePath, autofixResult.source, "utf-8")