@herb-tools/linter 0.7.4 → 0.8.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 (395) hide show
  1. package/README.md +253 -13
  2. package/dist/herb-lint.js +26087 -3414
  3. package/dist/herb-lint.js.map +1 -1
  4. package/dist/index.cjs +5783 -1568
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.js +5749 -1569
  7. package/dist/index.js.map +1 -1
  8. package/dist/loader.cjs +17010 -0
  9. package/dist/loader.cjs.map +1 -0
  10. package/dist/loader.js +16879 -0
  11. package/dist/loader.js.map +1 -0
  12. package/dist/package.json +13 -5
  13. package/dist/src/cli/argument-parser.js +42 -35
  14. package/dist/src/cli/argument-parser.js.map +1 -1
  15. package/dist/src/cli/file-processor.js +124 -23
  16. package/dist/src/cli/file-processor.js.map +1 -1
  17. package/dist/src/cli/formatters/detailed-formatter.js +18 -3
  18. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -1
  19. package/dist/src/cli/formatters/github-actions-formatter.js +15 -1
  20. package/dist/src/cli/formatters/github-actions-formatter.js.map +1 -1
  21. package/dist/src/cli/formatters/json-formatter.js +3 -0
  22. package/dist/src/cli/formatters/json-formatter.js.map +1 -1
  23. package/dist/src/cli/formatters/simple-formatter.js +20 -7
  24. package/dist/src/cli/formatters/simple-formatter.js.map +1 -1
  25. package/dist/src/cli/output-manager.js +22 -3
  26. package/dist/src/cli/output-manager.js.map +1 -1
  27. package/dist/src/cli/summary-reporter.js +26 -3
  28. package/dist/src/cli/summary-reporter.js.map +1 -1
  29. package/dist/src/cli.js +109 -43
  30. package/dist/src/cli.js.map +1 -1
  31. package/dist/src/custom-rule-loader.js +139 -0
  32. package/dist/src/custom-rule-loader.js.map +1 -0
  33. package/dist/src/herb-disable-comment-utils.js +129 -0
  34. package/dist/src/herb-disable-comment-utils.js.map +1 -0
  35. package/dist/src/index.js +1 -0
  36. package/dist/src/index.js.map +1 -1
  37. package/dist/src/linter.js +369 -34
  38. package/dist/src/linter.js.map +1 -1
  39. package/dist/src/loader.js +17 -0
  40. package/dist/src/loader.js.map +1 -0
  41. package/dist/src/rules/erb-comment-syntax.js +48 -0
  42. package/dist/src/rules/erb-comment-syntax.js.map +1 -0
  43. package/dist/src/rules/erb-no-case-node-children.js +52 -0
  44. package/dist/src/rules/erb-no-case-node-children.js.map +1 -0
  45. package/dist/src/rules/erb-no-empty-tags.js +7 -1
  46. package/dist/src/rules/erb-no-empty-tags.js.map +1 -1
  47. package/dist/src/rules/erb-no-extra-newline.js +65 -0
  48. package/dist/src/rules/erb-no-extra-newline.js.map +1 -0
  49. package/dist/src/rules/erb-no-extra-whitespace-inside-tags.js +95 -0
  50. package/dist/src/rules/erb-no-extra-whitespace-inside-tags.js.map +1 -0
  51. package/dist/src/rules/erb-no-output-control-flow.js +7 -1
  52. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -1
  53. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js +7 -1
  54. package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -1
  55. package/dist/src/rules/erb-prefer-image-tag-helper.js +7 -1
  56. package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
  57. package/dist/src/rules/erb-require-trailing-newline.js +35 -0
  58. package/dist/src/rules/erb-require-trailing-newline.js.map +1 -0
  59. package/dist/src/rules/erb-require-whitespace-inside-tags.js +70 -20
  60. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -1
  61. package/dist/src/rules/erb-right-trim.js +45 -0
  62. package/dist/src/rules/erb-right-trim.js.map +1 -0
  63. package/dist/src/rules/herb-disable-comment-base.js +51 -0
  64. package/dist/src/rules/herb-disable-comment-base.js.map +1 -0
  65. package/dist/src/rules/herb-disable-comment-malformed.js +51 -0
  66. package/dist/src/rules/herb-disable-comment-malformed.js.map +1 -0
  67. package/dist/src/rules/herb-disable-comment-missing-rules.js +29 -0
  68. package/dist/src/rules/herb-disable-comment-missing-rules.js.map +1 -0
  69. package/dist/src/rules/herb-disable-comment-no-duplicate-rules.js +32 -0
  70. package/dist/src/rules/herb-disable-comment-no-duplicate-rules.js.map +1 -0
  71. package/dist/src/rules/herb-disable-comment-no-redundant-all.js +31 -0
  72. package/dist/src/rules/herb-disable-comment-no-redundant-all.js.map +1 -0
  73. package/dist/src/rules/herb-disable-comment-unnecessary.js +65 -0
  74. package/dist/src/rules/herb-disable-comment-unnecessary.js.map +1 -0
  75. package/dist/src/rules/herb-disable-comment-valid-rule-name.js +44 -0
  76. package/dist/src/rules/herb-disable-comment-valid-rule-name.js.map +1 -0
  77. package/dist/src/rules/html-anchor-require-href.js +7 -1
  78. package/dist/src/rules/html-anchor-require-href.js.map +1 -1
  79. package/dist/src/rules/html-aria-attribute-must-be-valid.js +7 -1
  80. package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +1 -1
  81. package/dist/src/rules/html-aria-label-is-well-formatted.js +9 -3
  82. package/dist/src/rules/html-aria-label-is-well-formatted.js.map +1 -1
  83. package/dist/src/rules/html-aria-level-must-be-valid.js +6 -0
  84. package/dist/src/rules/html-aria-level-must-be-valid.js.map +1 -1
  85. package/dist/src/rules/html-aria-role-heading-requires-level.js +7 -1
  86. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -1
  87. package/dist/src/rules/html-aria-role-must-be-valid.js +7 -1
  88. package/dist/src/rules/html-aria-role-must-be-valid.js.map +1 -1
  89. package/dist/src/rules/html-attribute-double-quotes.js +29 -2
  90. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -1
  91. package/dist/src/rules/html-attribute-equals-spacing.js +18 -2
  92. package/dist/src/rules/html-attribute-equals-spacing.js.map +1 -1
  93. package/dist/src/rules/html-attribute-values-require-quotes.js +39 -3
  94. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -1
  95. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js +7 -1
  96. package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -1
  97. package/dist/src/rules/html-body-only-elements.js +46 -0
  98. package/dist/src/rules/html-body-only-elements.js.map +1 -0
  99. package/dist/src/rules/html-boolean-attributes-no-value.js +18 -1
  100. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -1
  101. package/dist/src/rules/html-head-only-elements.js +51 -0
  102. package/dist/src/rules/html-head-only-elements.js.map +1 -0
  103. package/dist/src/rules/html-iframe-has-title.js +8 -2
  104. package/dist/src/rules/html-iframe-has-title.js.map +1 -1
  105. package/dist/src/rules/html-img-require-alt.js +7 -1
  106. package/dist/src/rules/html-img-require-alt.js.map +1 -1
  107. package/dist/src/rules/html-input-require-autocomplete.js +70 -0
  108. package/dist/src/rules/html-input-require-autocomplete.js.map +1 -0
  109. package/dist/src/rules/html-navigation-has-label.js +7 -1
  110. package/dist/src/rules/html-navigation-has-label.js.map +1 -1
  111. package/dist/src/rules/html-no-aria-hidden-on-focusable.js +7 -1
  112. package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +1 -1
  113. package/dist/src/rules/html-no-block-inside-inline.js +7 -1
  114. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -1
  115. package/dist/src/rules/html-no-duplicate-attributes.js +7 -1
  116. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -1
  117. package/dist/src/rules/html-no-duplicate-ids.js +9 -3
  118. package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
  119. package/dist/src/rules/html-no-duplicate-meta-names.js +136 -0
  120. package/dist/src/rules/html-no-duplicate-meta-names.js.map +1 -0
  121. package/dist/src/rules/html-no-empty-attributes.js +45 -7
  122. package/dist/src/rules/html-no-empty-attributes.js.map +1 -1
  123. package/dist/src/rules/html-no-empty-headings.js +7 -6
  124. package/dist/src/rules/html-no-empty-headings.js.map +1 -1
  125. package/dist/src/rules/html-no-nested-links.js +7 -1
  126. package/dist/src/rules/html-no-nested-links.js.map +1 -1
  127. package/dist/src/rules/html-no-positive-tab-index.js +7 -1
  128. package/dist/src/rules/html-no-positive-tab-index.js.map +1 -1
  129. package/dist/src/rules/html-no-self-closing.js +48 -3
  130. package/dist/src/rules/html-no-self-closing.js.map +1 -1
  131. package/dist/src/rules/html-no-space-in-tag.js +173 -0
  132. package/dist/src/rules/html-no-space-in-tag.js.map +1 -0
  133. package/dist/src/rules/html-no-title-attribute.js +7 -1
  134. package/dist/src/rules/html-no-title-attribute.js.map +1 -1
  135. package/dist/src/rules/html-no-underscores-in-attribute-names.js +7 -1
  136. package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +1 -1
  137. package/dist/src/rules/html-tag-name-lowercase.js +23 -5
  138. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -1
  139. package/dist/src/rules/index.js +20 -2
  140. package/dist/src/rules/index.js.map +1 -1
  141. package/dist/src/rules/parser-no-errors.js +6 -0
  142. package/dist/src/rules/parser-no-errors.js.map +1 -1
  143. package/dist/src/rules/rule-utils.js +211 -31
  144. package/dist/src/rules/rule-utils.js.map +1 -1
  145. package/dist/src/rules/svg-tag-name-capitalization.js +22 -2
  146. package/dist/src/rules/svg-tag-name-capitalization.js.map +1 -1
  147. package/dist/src/{default-rules.js → rules.js} +46 -14
  148. package/dist/src/rules.js.map +1 -0
  149. package/dist/src/types.js +34 -1
  150. package/dist/src/types.js.map +1 -1
  151. package/dist/tsconfig.tsbuildinfo +1 -1
  152. package/dist/types/cli/argument-parser.d.ts +8 -2
  153. package/dist/types/cli/file-processor.d.ts +15 -0
  154. package/dist/types/cli/formatters/json-formatter.d.ts +6 -0
  155. package/dist/types/cli/formatters/simple-formatter.d.ts +1 -0
  156. package/dist/types/cli/summary-reporter.d.ts +6 -0
  157. package/dist/types/cli.d.ts +9 -4
  158. package/dist/types/custom-rule-loader.d.ts +62 -0
  159. package/dist/types/herb-disable-comment-utils.d.ts +69 -0
  160. package/dist/types/index.d.ts +1 -0
  161. package/dist/types/linter.d.ts +99 -3
  162. package/dist/types/loader.d.ts +20 -0
  163. package/dist/types/rules/erb-comment-syntax.d.ts +14 -0
  164. package/dist/types/rules/erb-no-case-node-children.d.ts +8 -0
  165. package/dist/types/rules/erb-no-empty-tags.d.ts +3 -2
  166. package/dist/types/rules/erb-no-extra-newline.d.ts +14 -0
  167. package/dist/types/rules/erb-no-extra-whitespace-inside-tags.d.ts +18 -0
  168. package/dist/types/rules/erb-no-output-control-flow.d.ts +3 -2
  169. package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +3 -2
  170. package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +3 -2
  171. package/dist/types/rules/erb-require-trailing-newline.d.ts +9 -0
  172. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +16 -5
  173. package/dist/types/rules/erb-right-trim.d.ts +14 -0
  174. package/dist/types/rules/herb-disable-comment-base.d.ts +37 -0
  175. package/dist/types/rules/herb-disable-comment-malformed.d.ts +8 -0
  176. package/dist/types/rules/herb-disable-comment-missing-rules.d.ts +8 -0
  177. package/dist/types/rules/herb-disable-comment-no-duplicate-rules.d.ts +8 -0
  178. package/dist/types/rules/herb-disable-comment-no-redundant-all.d.ts +8 -0
  179. package/dist/types/rules/herb-disable-comment-unnecessary.d.ts +8 -0
  180. package/dist/types/rules/herb-disable-comment-valid-rule-name.d.ts +8 -0
  181. package/dist/types/rules/html-anchor-require-href.d.ts +3 -2
  182. package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +3 -2
  183. package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +3 -2
  184. package/dist/types/rules/html-aria-level-must-be-valid.d.ts +3 -2
  185. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +3 -2
  186. package/dist/types/rules/html-aria-role-must-be-valid.d.ts +3 -2
  187. package/dist/types/rules/html-attribute-double-quotes.d.ts +13 -5
  188. package/dist/types/rules/html-attribute-equals-spacing.d.ts +12 -5
  189. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +13 -5
  190. package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +3 -2
  191. package/dist/types/rules/html-body-only-elements.d.ts +9 -0
  192. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +12 -5
  193. package/dist/types/rules/html-head-only-elements.d.ts +9 -0
  194. package/dist/types/rules/html-iframe-has-title.d.ts +3 -2
  195. package/dist/types/rules/html-img-require-alt.d.ts +3 -2
  196. package/dist/types/rules/html-input-require-autocomplete.d.ts +8 -0
  197. package/dist/types/rules/html-navigation-has-label.d.ts +3 -2
  198. package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +3 -2
  199. package/dist/types/rules/html-no-block-inside-inline.d.ts +3 -2
  200. package/dist/types/rules/html-no-duplicate-attributes.d.ts +3 -2
  201. package/dist/types/rules/html-no-duplicate-ids.d.ts +3 -2
  202. package/dist/types/rules/html-no-duplicate-meta-names.d.ts +9 -0
  203. package/dist/types/rules/html-no-empty-attributes.d.ts +3 -2
  204. package/dist/types/rules/html-no-empty-headings.d.ts +3 -2
  205. package/dist/types/rules/html-no-nested-links.d.ts +3 -2
  206. package/dist/types/rules/html-no-positive-tab-index.d.ts +3 -2
  207. package/dist/types/rules/html-no-self-closing.d.ts +14 -5
  208. package/dist/types/rules/html-no-space-in-tag.d.ts +16 -0
  209. package/dist/types/rules/html-no-title-attribute.d.ts +3 -2
  210. package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +3 -2
  211. package/dist/types/rules/html-tag-name-lowercase.d.ts +16 -6
  212. package/dist/types/rules/index.d.ts +20 -2
  213. package/dist/types/rules/parser-no-errors.d.ts +2 -1
  214. package/dist/types/rules/rule-utils.d.ts +72 -25
  215. package/dist/types/rules/svg-tag-name-capitalization.d.ts +13 -4
  216. package/dist/types/rules.d.ts +2 -0
  217. package/dist/types/src/cli/argument-parser.d.ts +8 -2
  218. package/dist/types/src/cli/file-processor.d.ts +15 -0
  219. package/dist/types/src/cli/formatters/json-formatter.d.ts +6 -0
  220. package/dist/types/src/cli/formatters/simple-formatter.d.ts +1 -0
  221. package/dist/types/src/cli/summary-reporter.d.ts +6 -0
  222. package/dist/types/src/cli.d.ts +9 -4
  223. package/dist/types/src/custom-rule-loader.d.ts +62 -0
  224. package/dist/types/src/herb-disable-comment-utils.d.ts +69 -0
  225. package/dist/types/src/index.d.ts +1 -0
  226. package/dist/types/src/linter.d.ts +99 -3
  227. package/dist/types/src/loader.d.ts +20 -0
  228. package/dist/types/src/rules/erb-comment-syntax.d.ts +14 -0
  229. package/dist/types/src/rules/erb-no-case-node-children.d.ts +8 -0
  230. package/dist/types/src/rules/erb-no-empty-tags.d.ts +3 -2
  231. package/dist/types/src/rules/erb-no-extra-newline.d.ts +14 -0
  232. package/dist/types/src/rules/erb-no-extra-whitespace-inside-tags.d.ts +18 -0
  233. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +3 -2
  234. package/dist/types/src/rules/erb-no-silent-tag-in-attribute-name.d.ts +3 -2
  235. package/dist/types/src/rules/erb-prefer-image-tag-helper.d.ts +3 -2
  236. package/dist/types/src/rules/erb-require-trailing-newline.d.ts +9 -0
  237. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +16 -5
  238. package/dist/types/src/rules/erb-right-trim.d.ts +14 -0
  239. package/dist/types/src/rules/herb-disable-comment-base.d.ts +37 -0
  240. package/dist/types/src/rules/herb-disable-comment-malformed.d.ts +8 -0
  241. package/dist/types/src/rules/herb-disable-comment-missing-rules.d.ts +8 -0
  242. package/dist/types/src/rules/herb-disable-comment-no-duplicate-rules.d.ts +8 -0
  243. package/dist/types/src/rules/herb-disable-comment-no-redundant-all.d.ts +8 -0
  244. package/dist/types/src/rules/herb-disable-comment-unnecessary.d.ts +8 -0
  245. package/dist/types/src/rules/herb-disable-comment-valid-rule-name.d.ts +8 -0
  246. package/dist/types/src/rules/html-anchor-require-href.d.ts +3 -2
  247. package/dist/types/src/rules/html-aria-attribute-must-be-valid.d.ts +3 -2
  248. package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +3 -2
  249. package/dist/types/src/rules/html-aria-level-must-be-valid.d.ts +3 -2
  250. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +3 -2
  251. package/dist/types/src/rules/html-aria-role-must-be-valid.d.ts +3 -2
  252. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +13 -5
  253. package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +12 -5
  254. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +13 -5
  255. package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +3 -2
  256. package/dist/types/src/rules/html-body-only-elements.d.ts +9 -0
  257. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +12 -5
  258. package/dist/types/src/rules/html-head-only-elements.d.ts +9 -0
  259. package/dist/types/src/rules/html-iframe-has-title.d.ts +3 -2
  260. package/dist/types/src/rules/html-img-require-alt.d.ts +3 -2
  261. package/dist/types/src/rules/html-input-require-autocomplete.d.ts +8 -0
  262. package/dist/types/src/rules/html-navigation-has-label.d.ts +3 -2
  263. package/dist/types/src/rules/html-no-aria-hidden-on-focusable.d.ts +3 -2
  264. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +3 -2
  265. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +3 -2
  266. package/dist/types/src/rules/html-no-duplicate-ids.d.ts +3 -2
  267. package/dist/types/src/rules/html-no-duplicate-meta-names.d.ts +9 -0
  268. package/dist/types/src/rules/html-no-empty-attributes.d.ts +3 -2
  269. package/dist/types/src/rules/html-no-empty-headings.d.ts +3 -2
  270. package/dist/types/src/rules/html-no-nested-links.d.ts +3 -2
  271. package/dist/types/src/rules/html-no-positive-tab-index.d.ts +3 -2
  272. package/dist/types/src/rules/html-no-self-closing.d.ts +14 -5
  273. package/dist/types/src/rules/html-no-space-in-tag.d.ts +16 -0
  274. package/dist/types/src/rules/html-no-title-attribute.d.ts +3 -2
  275. package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +3 -2
  276. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +16 -6
  277. package/dist/types/src/rules/index.d.ts +20 -2
  278. package/dist/types/src/rules/parser-no-errors.d.ts +2 -1
  279. package/dist/types/src/rules/rule-utils.d.ts +72 -25
  280. package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +13 -4
  281. package/dist/types/src/rules.d.ts +2 -0
  282. package/dist/types/src/types.d.ts +102 -11
  283. package/dist/types/types.d.ts +102 -11
  284. package/docs/rules/README.md +19 -3
  285. package/docs/rules/erb-comment-syntax.md +44 -0
  286. package/docs/rules/erb-no-case-node-children.md +50 -0
  287. package/docs/rules/erb-no-extra-newline.md +74 -0
  288. package/docs/rules/erb-no-extra-whitespace-inside-tags.md +39 -0
  289. package/docs/rules/{erb-requires-trailing-newline.md → erb-require-trailing-newline.md} +1 -1
  290. package/docs/rules/erb-right-trim.md +52 -0
  291. package/docs/rules/herb-disable-comment-malformed.md +45 -0
  292. package/docs/rules/herb-disable-comment-missing-rules.md +60 -0
  293. package/docs/rules/herb-disable-comment-no-duplicate-rules.md +49 -0
  294. package/docs/rules/herb-disable-comment-no-redundant-all.md +53 -0
  295. package/docs/rules/herb-disable-comment-unnecessary.md +44 -0
  296. package/docs/rules/herb-disable-comment-valid-rule-name.md +41 -0
  297. package/docs/rules/html-aria-attribute-must-be-valid.md +2 -5
  298. package/docs/rules/html-aria-label-is-well-formatted.md +1 -1
  299. package/docs/rules/html-attribute-double-quotes.md +2 -2
  300. package/docs/rules/html-attribute-equals-spacing.md +2 -2
  301. package/docs/rules/html-attribute-values-require-quotes.md +3 -3
  302. package/docs/rules/html-avoid-both-disabled-and-aria-disabled.md +2 -2
  303. package/docs/rules/html-body-only-elements.md +99 -0
  304. package/docs/rules/html-boolean-attributes-no-value.md +2 -2
  305. package/docs/rules/html-head-only-elements.md +81 -0
  306. package/docs/rules/html-input-require-autocomplete.md +64 -0
  307. package/docs/rules/html-no-aria-hidden-on-focusable.md +2 -2
  308. package/docs/rules/html-no-duplicate-attributes.md +2 -2
  309. package/docs/rules/html-no-duplicate-meta-names.md +64 -0
  310. package/docs/rules/html-no-empty-attributes.md +3 -3
  311. package/docs/rules/html-no-empty-headings.md +4 -26
  312. package/docs/rules/html-no-positive-tab-index.md +1 -2
  313. package/docs/rules/html-no-self-closing.md +17 -2
  314. package/docs/rules/html-no-space-in-tag.md +66 -0
  315. package/docs/rules/html-no-title-attribute.md +2 -2
  316. package/docs/rules/html-no-underscores-in-attribute-names.md +2 -2
  317. package/docs/rules/html-tag-name-lowercase.md +2 -2
  318. package/package.json +13 -5
  319. package/src/cli/argument-parser.ts +50 -39
  320. package/src/cli/file-processor.ts +159 -28
  321. package/src/cli/formatters/detailed-formatter.ts +21 -3
  322. package/src/cli/formatters/github-actions-formatter.ts +17 -1
  323. package/src/cli/formatters/json-formatter.ts +9 -0
  324. package/src/cli/formatters/simple-formatter.ts +24 -8
  325. package/src/cli/output-manager.ts +23 -3
  326. package/src/cli/summary-reporter.ts +40 -3
  327. package/src/cli.ts +137 -52
  328. package/src/custom-rule-loader.ts +189 -0
  329. package/src/herb-disable-comment-utils.ts +175 -0
  330. package/src/index.ts +2 -0
  331. package/src/linter.ts +501 -36
  332. package/src/loader.ts +30 -0
  333. package/src/rules/erb-comment-syntax.ts +73 -0
  334. package/src/rules/erb-no-case-node-children.ts +68 -0
  335. package/src/rules/erb-no-empty-tags.ts +9 -3
  336. package/src/rules/erb-no-extra-newline.ts +91 -0
  337. package/src/rules/erb-no-extra-whitespace-inside-tags.ts +147 -0
  338. package/src/rules/erb-no-output-control-flow.ts +9 -3
  339. package/src/rules/erb-no-silent-tag-in-attribute-name.ts +9 -3
  340. package/src/rules/erb-prefer-image-tag-helper.ts +9 -3
  341. package/src/rules/erb-require-trailing-newline.ts +47 -0
  342. package/src/rules/erb-require-whitespace-inside-tags.ts +96 -26
  343. package/src/rules/erb-right-trim.ts +67 -0
  344. package/src/rules/herb-disable-comment-base.ts +76 -0
  345. package/src/rules/herb-disable-comment-malformed.ts +66 -0
  346. package/src/rules/herb-disable-comment-missing-rules.ts +41 -0
  347. package/src/rules/herb-disable-comment-no-duplicate-rules.ts +46 -0
  348. package/src/rules/herb-disable-comment-no-redundant-all.ts +40 -0
  349. package/src/rules/herb-disable-comment-unnecessary.ts +103 -0
  350. package/src/rules/herb-disable-comment-valid-rule-name.ts +62 -0
  351. package/src/rules/html-anchor-require-href.ts +9 -3
  352. package/src/rules/html-aria-attribute-must-be-valid.ts +9 -3
  353. package/src/rules/html-aria-label-is-well-formatted.ts +9 -5
  354. package/src/rules/html-aria-level-must-be-valid.ts +9 -2
  355. package/src/rules/html-aria-role-heading-requires-level.ts +9 -3
  356. package/src/rules/html-aria-role-must-be-valid.ts +9 -3
  357. package/src/rules/html-attribute-double-quotes.ts +42 -8
  358. package/src/rules/html-attribute-equals-spacing.ts +31 -7
  359. package/src/rules/html-attribute-values-require-quotes.ts +56 -10
  360. package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +9 -3
  361. package/src/rules/html-body-only-elements.ts +60 -0
  362. package/src/rules/html-boolean-attributes-no-value.ts +31 -6
  363. package/src/rules/html-head-only-elements.ts +65 -0
  364. package/src/rules/html-iframe-has-title.ts +9 -4
  365. package/src/rules/html-img-require-alt.ts +10 -4
  366. package/src/rules/html-input-require-autocomplete.ts +85 -0
  367. package/src/rules/html-navigation-has-label.ts +9 -3
  368. package/src/rules/html-no-aria-hidden-on-focusable.ts +9 -3
  369. package/src/rules/html-no-block-inside-inline.ts +9 -3
  370. package/src/rules/html-no-duplicate-attributes.ts +9 -3
  371. package/src/rules/html-no-duplicate-ids.ts +11 -7
  372. package/src/rules/html-no-duplicate-meta-names.ts +188 -0
  373. package/src/rules/html-no-empty-attributes.ts +58 -10
  374. package/src/rules/html-no-empty-headings.ts +10 -8
  375. package/src/rules/html-no-nested-links.ts +10 -4
  376. package/src/rules/html-no-positive-tab-index.ts +9 -3
  377. package/src/rules/html-no-self-closing.ts +69 -9
  378. package/src/rules/html-no-space-in-tag.ts +221 -0
  379. package/src/rules/html-no-title-attribute.ts +9 -3
  380. package/src/rules/html-no-underscores-in-attribute-names.ts +12 -4
  381. package/src/rules/html-tag-name-lowercase.ts +41 -10
  382. package/src/rules/index.ts +24 -2
  383. package/src/rules/parser-no-errors.ts +8 -1
  384. package/src/rules/rule-utils.ts +250 -44
  385. package/src/rules/svg-tag-name-capitalization.ts +39 -6
  386. package/src/{default-rules.ts → rules.ts} +53 -13
  387. package/src/types.ts +133 -15
  388. package/dist/src/default-rules.js.map +0 -1
  389. package/dist/src/rules/erb-requires-trailing-newline.js +0 -22
  390. package/dist/src/rules/erb-requires-trailing-newline.js.map +0 -1
  391. package/dist/types/default-rules.d.ts +0 -2
  392. package/dist/types/rules/erb-requires-trailing-newline.d.ts +0 -6
  393. package/dist/types/src/default-rules.d.ts +0 -2
  394. package/dist/types/src/rules/erb-requires-trailing-newline.d.ts +0 -6
  395. package/src/rules/erb-requires-trailing-newline.ts +0 -29
package/src/linter.ts CHANGED
@@ -1,30 +1,154 @@
1
- import { defaultRules } from "./default-rules.js"
1
+ import { Location } from "@herb-tools/core"
2
+ import { IdentityPrinter } from "@herb-tools/printer"
3
+ import { minimatch } from "minimatch"
2
4
 
3
- import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, LintContext } from "./types.js"
4
- import type { HerbBackend } from "@herb-tools/core"
5
+ import { rules } from "./rules.js"
6
+ import { findNodeByLocation } from "./rules/rule-utils.js"
7
+ import { parseHerbDisableLine } from "./herb-disable-comment-utils.js"
8
+
9
+ import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
10
+ import { DEFAULT_RULE_CONFIG } from "./types.js"
11
+
12
+ import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, UnboundLintOffense, LintContext, AutofixResult } from "./types.js"
13
+ import type { ParseResult, LexResult, HerbBackend } from "@herb-tools/core"
14
+ import type { RuleConfig, Config } from "@herb-tools/config"
15
+
16
+ export interface LinterOptions {
17
+ /**
18
+ * Array of rule classes to use. If not provided, uses default rules.
19
+ */
20
+ rules?: RuleClass[]
21
+
22
+ /**
23
+ * Whether to load custom rules from the project.
24
+ * Defaults to false for backward compatibility.
25
+ */
26
+ loadCustomRules?: boolean
27
+
28
+ /**
29
+ * Base directory to search for custom rules.
30
+ * Defaults to current working directory.
31
+ */
32
+ customRulesBaseDir?: string
33
+
34
+ /**
35
+ * Custom glob patterns to search for rule files.
36
+ */
37
+ customRulesPatterns?: string[]
38
+
39
+ /**
40
+ * Whether to suppress custom rule loading errors.
41
+ * Defaults to false.
42
+ */
43
+ silentCustomRules?: boolean
44
+ }
5
45
 
6
46
  export class Linter {
7
47
  protected rules: RuleClass[]
48
+ protected allAvailableRules: RuleClass[]
8
49
  protected herb: HerbBackend
9
50
  protected offenses: LintOffense[]
51
+ protected config?: Config
52
+
53
+ /**
54
+ * Creates a new Linter instance with automatic rule filtering based on config.
55
+ *
56
+ * @param herb - The Herb backend instance for parsing and lexing
57
+ * @param config - Optional full Config instance for rule filtering, severity overrides, and path-based filtering
58
+ * @param customRules - Optional array of custom rules to include alongside built-in rules
59
+ * @returns A configured Linter instance
60
+ */
61
+ static from(herb: HerbBackend, config?: Config, customRules?: RuleClass[]): Linter {
62
+ const allRules = customRules ? [...rules, ...customRules] : rules
63
+ const filteredRules = config?.linter?.rules
64
+ ? Linter.filterRulesByConfig(allRules, config.linter.rules)
65
+ : undefined
66
+
67
+ return new Linter(herb, filteredRules, config, allRules)
68
+ }
10
69
 
11
70
  /**
12
71
  * Creates a new Linter instance.
72
+ *
73
+ * For most use cases, prefer `Linter.from()` which handles config-based filtering.
74
+ * Use this constructor directly when you need explicit control over rules.
75
+ *
13
76
  * @param herb - The Herb backend instance for parsing and lexing
14
77
  * @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
78
+ * @param config - Optional full Config instance for severity overrides and path-based rule filtering
79
+ * @param allAvailableRules - Optional array of ALL available rules (including disabled) for herb:disable validation
15
80
  */
16
- constructor(herb: HerbBackend, rules?: RuleClass[]) {
81
+ constructor(herb: HerbBackend, rules?: RuleClass[], config?: Config, allAvailableRules?: RuleClass[]) {
17
82
  this.herb = herb
83
+ this.config = config
18
84
  this.rules = rules !== undefined ? rules : this.getDefaultRules()
85
+ this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules
19
86
  this.offenses = []
20
87
  }
21
88
 
89
+ /**
90
+ * Filters rules based on default config and optional user config overrides.
91
+ *
92
+ * Priority:
93
+ * 1. User config override (if rule config exists in userRulesConfig)
94
+ * 2. Default config from rule's defaultConfig getter
95
+ *
96
+ * @param allRules - All available rule classes to filter from
97
+ * @param userRulesConfig - Optional user configuration for rules
98
+ * @returns Filtered array of rule classes that should be enabled
99
+ */
100
+ static filterRulesByConfig(
101
+ allRules: RuleClass[],
102
+ userRulesConfig?: Record<string, RuleConfig>
103
+ ): RuleClass[] {
104
+ return allRules.filter(ruleClass => {
105
+ const instance = new ruleClass()
106
+ const ruleName = instance.name
107
+
108
+ const defaultEnabled = instance.defaultConfig?.enabled ?? DEFAULT_RULE_CONFIG.enabled
109
+ const userRuleConfig = userRulesConfig?.[ruleName]
110
+
111
+ if (userRuleConfig !== undefined) {
112
+ return userRuleConfig.enabled !== false
113
+ }
114
+
115
+ return defaultEnabled
116
+ })
117
+ }
118
+
22
119
  /**
23
120
  * Returns the default set of rule classes used by the linter.
24
- * @returns Array of rule classes
121
+ * These are the rules enabled when no custom rules are provided.
122
+ * Filters all available rules to only include those enabled by default.
123
+ * @returns Array of default rule classes
25
124
  */
26
125
  protected getDefaultRules(): RuleClass[] {
27
- return defaultRules
126
+ return Linter.filterRulesByConfig(rules)
127
+ }
128
+
129
+ /**
130
+ * Returns all available rule classes that can be referenced in herb:disable comments.
131
+ * This includes all rules that exist, regardless of whether they're currently enabled.
132
+ * Includes both built-in rules and any loaded custom rules.
133
+ * @returns Array of all available rule classes
134
+ */
135
+ protected getAvailableRules(): RuleClass[] {
136
+ return this.allAvailableRules
137
+ }
138
+
139
+ /**
140
+ * Meta-linting rules for herb:disable comments cannot be disabled
141
+ * This ensures that invalid herb:disable comments are always caught
142
+ */
143
+ protected get nonExcludableRules() {
144
+ return [
145
+ "herb-disable-comment-valid-rule-name",
146
+ "herb-disable-comment-no-redundant-all",
147
+ "herb-disable-comment-no-duplicate-rules",
148
+ "herb-disable-comment-malformed",
149
+ "herb-disable-comment-missing-rules",
150
+ "herb-disable-comment-unnecessary"
151
+ ]
28
152
  }
29
153
 
30
154
  getRuleCount(): number {
@@ -45,6 +169,128 @@ export class Linter {
45
169
  return (rule.constructor as any).type === "source"
46
170
  }
47
171
 
172
+ /**
173
+ * Execute a single rule and return its unbound offenses.
174
+ * Handles rule type checking (Lexer/Parser/Source) and isEnabled checks.
175
+ */
176
+ private executeRule(
177
+ rule: Rule,
178
+ parseResult: ParseResult,
179
+ lexResult: LexResult,
180
+ source: string,
181
+ context?: Partial<LintContext>
182
+ ): UnboundLintOffense[] {
183
+ if (this.config && context?.fileName) {
184
+ if (!this.config.isRuleEnabledForPath(rule.name, context.fileName)) {
185
+ return []
186
+ }
187
+ }
188
+
189
+ if (context?.fileName && !this.config?.linter?.rules?.[rule.name]?.exclude) {
190
+ const defaultExclude = rule.defaultConfig?.exclude ?? DEFAULT_RULE_CONFIG.exclude
191
+
192
+ if (defaultExclude && defaultExclude.length > 0) {
193
+ const isExcluded = defaultExclude.some((pattern: string) => minimatch(context.fileName!, pattern))
194
+
195
+ if (isExcluded) {
196
+ return []
197
+ }
198
+ }
199
+ }
200
+
201
+ let isEnabled = true
202
+ let ruleOffenses: UnboundLintOffense[]
203
+
204
+ if (this.isLexerRule(rule)) {
205
+ if (rule.isEnabled) {
206
+ isEnabled = rule.isEnabled(lexResult, context)
207
+ }
208
+
209
+ if (isEnabled) {
210
+ ruleOffenses = (rule as LexerRule).check(lexResult, context)
211
+ } else {
212
+ ruleOffenses = []
213
+ }
214
+
215
+ } else if (this.isSourceRule(rule)) {
216
+ if (rule.isEnabled) {
217
+ isEnabled = rule.isEnabled(source, context)
218
+ }
219
+
220
+ if (isEnabled) {
221
+ ruleOffenses = (rule as SourceRule).check(source, context)
222
+ } else {
223
+ ruleOffenses = []
224
+ }
225
+ } else {
226
+ if (rule.isEnabled) {
227
+ isEnabled = rule.isEnabled(parseResult, context)
228
+ }
229
+
230
+ if (isEnabled) {
231
+ ruleOffenses = (rule as ParserRule).check(parseResult, context)
232
+ } else {
233
+ ruleOffenses = []
234
+ }
235
+ }
236
+
237
+ return ruleOffenses
238
+ }
239
+
240
+ private filterOffenses(
241
+ ruleOffenses: LintOffense[],
242
+ ruleName: string,
243
+ ignoredOffensesByLine?: Map<number, Set<string>>,
244
+ herbDisableCache?: Map<number, string[]>,
245
+ ignoreDisableComments?: boolean
246
+ ): { kept: LintOffense[], ignored: LintOffense[], wouldBeIgnored: LintOffense[] } {
247
+ const kept: LintOffense[] = []
248
+ const ignored: LintOffense[] = []
249
+ const wouldBeIgnored: LintOffense[] = []
250
+
251
+ if (this.nonExcludableRules.includes(ruleName)) {
252
+ return { kept: ruleOffenses, ignored: [], wouldBeIgnored: [] }
253
+ }
254
+
255
+ if (ignoreDisableComments) {
256
+ for (const offense of ruleOffenses) {
257
+ const line = offense.location.start.line
258
+ const disabledRules = herbDisableCache?.get(line) || []
259
+
260
+ if (disabledRules.includes(ruleName) || disabledRules.includes("all")) {
261
+ wouldBeIgnored.push(offense)
262
+ }
263
+ }
264
+
265
+ return { kept: ruleOffenses, ignored: [], wouldBeIgnored }
266
+ }
267
+
268
+ for (const offense of ruleOffenses) {
269
+ const line = offense.location.start.line
270
+ const disabledRules = herbDisableCache?.get(line) || []
271
+
272
+ if (disabledRules.includes(ruleName) || disabledRules.includes("all")) {
273
+ ignored.push(offense)
274
+
275
+ if (ignoredOffensesByLine) {
276
+ if (!ignoredOffensesByLine.has(line)) {
277
+ ignoredOffensesByLine.set(line, new Set())
278
+ }
279
+
280
+ const usedRuleName = disabledRules.includes(ruleName) ? ruleName : "all"
281
+ ignoredOffensesByLine.get(line)!.add(usedRuleName)
282
+ }
283
+
284
+ continue
285
+ }
286
+
287
+ kept.push(offense)
288
+ }
289
+
290
+ return { kept, ignored, wouldBeIgnored: [] }
291
+ }
292
+
293
+
48
294
  /**
49
295
  * Lint source code using Parser/AST, Lexer, and Source rules.
50
296
  * @param source - The source code to lint
@@ -53,58 +299,277 @@ export class Linter {
53
299
  lint(source: string, context?: Partial<LintContext>): LintResult {
54
300
  this.offenses = []
55
301
 
302
+ let ignoredCount = 0
303
+ let wouldBeIgnoredCount = 0
304
+
56
305
  const parseResult = this.herb.parse(source, { track_whitespace: true })
57
306
  const lexResult = this.herb.lex(source)
307
+ const hasParserErrors = parseResult.recursiveErrors().length > 0
308
+ const sourceLines = source.split("\n")
309
+ const ignoredOffensesByLine = new Map<number, Set<string>>()
310
+ const herbDisableCache = new Map<number, string[]>()
311
+
312
+ if (hasParserErrors) {
313
+ const hasParserRule = this.rules.find(RuleClass => (new RuleClass()).name === "parser-no-errors")
314
+
315
+ if (hasParserRule) {
316
+ const rule = new ParserNoErrorsRule()
317
+ const offenses = rule.check(parseResult)
318
+ this.offenses.push(...offenses)
319
+ }
320
+
321
+ return {
322
+ offenses: this.offenses,
323
+ errors: this.offenses.filter(o => o.severity === "error").length,
324
+ warnings: this.offenses.filter(o => o.severity === "warning").length,
325
+ info: this.offenses.filter(o => o.severity === "info").length,
326
+ hints: this.offenses.filter(o => o.severity === "hint").length,
327
+ ignored: 0
328
+ }
329
+ }
58
330
 
59
- for (const RuleClass of this.rules) {
331
+ for (let i = 0; i < sourceLines.length; i++) {
332
+ const line = sourceLines[i]
333
+
334
+ if (line.includes("herb:disable")) {
335
+ const herbDisable = parseHerbDisableLine(line)
336
+ herbDisableCache.set(i + 1, herbDisable?.ruleNames || [])
337
+ }
338
+ }
339
+
340
+ context = {
341
+ ...context,
342
+ validRuleNames: this.getAvailableRules().map(RuleClass => new RuleClass().name),
343
+ ignoredOffensesByLine
344
+ }
345
+
346
+ const regularRules = this.rules.filter(RuleClass => {
60
347
  const rule = new RuleClass()
61
348
 
62
- let isEnabled = true
63
- let ruleOffenses: LintOffense[]
349
+ return rule.name !== "herb-disable-comment-unnecessary"
350
+ })
351
+
352
+ for (const RuleClass of regularRules) {
353
+ const rule = new RuleClass()
354
+ const unboundOffenses = this.executeRule(rule, parseResult, lexResult, source, context)
355
+ const boundOffenses = this.bindSeverity(unboundOffenses, rule.name)
356
+
357
+ const { kept, ignored, wouldBeIgnored } = this.filterOffenses(
358
+ boundOffenses,
359
+ rule.name,
360
+ ignoredOffensesByLine,
361
+ herbDisableCache,
362
+ context?.ignoreDisableComments
363
+ )
364
+
365
+ ignoredCount += ignored.length
366
+ wouldBeIgnoredCount += wouldBeIgnored.length
367
+ this.offenses.push(...kept)
368
+ }
369
+
370
+ const unnecessaryRuleClass = this.rules.find(RuleClass => {
371
+ const rule = new RuleClass()
372
+
373
+ return rule.name === "herb-disable-comment-unnecessary"
374
+ })
375
+
376
+ if (unnecessaryRuleClass) {
377
+ const unnecessaryRule = new unnecessaryRuleClass() as ParserRule
378
+ const unboundOffenses = unnecessaryRule.check(parseResult, context)
379
+ const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRule.name)
380
+
381
+ this.offenses.push(...boundOffenses)
382
+ }
383
+
384
+ const finalOffenses = this.offenses
385
+
386
+ const errors = finalOffenses.filter(offense => offense.severity === "error").length
387
+ const warnings = finalOffenses.filter(offense => offense.severity === "warning").length
388
+ const info = finalOffenses.filter(offense => offense.severity === "info").length
389
+ const hints = finalOffenses.filter(offense => offense.severity === "hint").length
390
+
391
+ const result: LintResult = {
392
+ offenses: finalOffenses,
393
+ errors,
394
+ warnings,
395
+ info,
396
+ hints,
397
+ ignored: ignoredCount
398
+ }
399
+
400
+ if (wouldBeIgnoredCount > 0) {
401
+ result.wouldBeIgnored = wouldBeIgnoredCount
402
+ }
403
+
404
+ return result
405
+ }
406
+
407
+ /**
408
+ * Bind severity to unbound offenses based on rule's defaultConfig and user config overrides.
409
+ *
410
+ * Priority:
411
+ * 1. User config severity override (if specified in config)
412
+ * 2. Rule's default severity (from defaultConfig.severity)
413
+ *
414
+ * @param unboundOffenses - Array of offenses without severity
415
+ * @param ruleName - Name of the rule that produced the offenses
416
+ * @returns Array of offenses with severity bound
417
+ */
418
+ protected bindSeverity(unboundOffenses: UnboundLintOffense[], ruleName: string): LintOffense[] {
419
+ const RuleClass = this.rules.find(rule => {
420
+ const instance = new rule()
421
+ return instance.name === ruleName
422
+ })
423
+
424
+ if (!RuleClass) {
425
+ return unboundOffenses.map(offense => ({
426
+ ...offense,
427
+ severity: "error" as const
428
+ }))
429
+ }
430
+
431
+ const ruleInstance = new RuleClass()
432
+ const defaultSeverity = ruleInstance.defaultConfig?.severity ?? DEFAULT_RULE_CONFIG.severity
64
433
 
65
- if (this.isLexerRule(rule)) {
66
- if (rule.isEnabled) {
67
- isEnabled = rule.isEnabled(lexResult, context)
434
+ const userRuleConfig = this.config?.linter?.rules?.[ruleName]
435
+ const severity = userRuleConfig?.severity ?? defaultSeverity
436
+
437
+ return unboundOffenses.map(offense => ({
438
+ ...offense,
439
+ severity
440
+ }))
441
+ }
442
+
443
+ /**
444
+ * Automatically fix offenses in the source code.
445
+ * Uses AST mutation for parser rules and token mutation for lexer rules.
446
+ * @param source - The source code to fix
447
+ * @param context - Optional context for linting (e.g., fileName)
448
+ * @param offensesToFix - Optional array of specific offenses to fix. If not provided, all fixable offenses will be fixed.
449
+ * @returns AutofixResult containing the corrected source and lists of fixed/unfixed offenses
450
+ */
451
+ autofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[]): AutofixResult {
452
+ const lintResult = offensesToFix ? { offenses: offensesToFix } : this.lint(source, context)
453
+
454
+ const parserOffenses: LintOffense[] = []
455
+ const lexerOffenses: LintOffense[] = []
456
+ const sourceOffenses: LintOffense[] = []
457
+
458
+ for (const offense of lintResult.offenses) {
459
+ const RuleClass = this.rules.find(rule => {
460
+ const instance = new rule()
461
+
462
+ return instance.name === offense.rule
463
+ })
464
+
465
+ if (!RuleClass) continue
466
+
467
+ if ((RuleClass as any).type === "lexer") {
468
+ lexerOffenses.push(offense)
469
+ } else if ((RuleClass as any).type === "source") {
470
+ sourceOffenses.push(offense)
471
+ } else {
472
+ parserOffenses.push(offense)
473
+ }
474
+ }
475
+
476
+ let currentSource = source
477
+ const fixed: LintOffense[] = []
478
+ const unfixed: LintOffense[] = []
479
+
480
+ if (parserOffenses.length > 0) {
481
+ const parseResult = this.herb.parse(currentSource, { track_whitespace: true })
482
+
483
+ for (const offense of parserOffenses) {
484
+ const RuleClass = this.rules.find(rule => new rule().name === offense.rule)
485
+
486
+ if (!RuleClass) {
487
+ unfixed.push(offense)
488
+
489
+ continue
68
490
  }
69
491
 
70
- if (isEnabled) {
71
- ruleOffenses = (rule as LexerRule).check(lexResult, context)
72
- } else {
73
- ruleOffenses = []
492
+ const rule = new RuleClass() as ParserRule
493
+
494
+ if (!rule.autofix) {
495
+ unfixed.push(offense)
496
+
497
+ continue
74
498
  }
75
499
 
76
- } else if (this.isSourceRule(rule)) {
77
- if (rule.isEnabled) {
78
- isEnabled = rule.isEnabled(source, context)
500
+ if (offense.autofixContext) {
501
+ const originalNodeType = offense.autofixContext.node.type
502
+ const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
503
+
504
+ const freshNode = findNodeByLocation(
505
+ parseResult.value,
506
+ location,
507
+ (node) => node.type === originalNodeType
508
+ )
509
+
510
+ if (freshNode) {
511
+ offense.autofixContext.node = freshNode
512
+ } else {
513
+ unfixed.push(offense)
514
+
515
+ continue
516
+ }
79
517
  }
80
518
 
81
- if (isEnabled) {
82
- ruleOffenses = (rule as SourceRule).check(source, context)
519
+ const fixedResult = rule.autofix(offense, parseResult, context)
520
+
521
+ if (fixedResult) {
522
+ fixed.push(offense)
83
523
  } else {
84
- ruleOffenses = []
524
+ unfixed.push(offense)
85
525
  }
86
- } else {
87
- if (rule.isEnabled) {
88
- isEnabled = rule.isEnabled(parseResult, context)
526
+ }
527
+
528
+ if (fixed.length > 0) {
529
+ const printer = new IdentityPrinter()
530
+ currentSource = printer.print(parseResult.value)
531
+ }
532
+ }
533
+
534
+ if (sourceOffenses.length > 0) {
535
+ const sortedSourceOffenses = sourceOffenses.sort((a, b) => {
536
+ if (a.location.start.line !== b.location.start.line) {
537
+ return b.location.start.line - a.location.start.line
89
538
  }
90
539
 
91
- if (isEnabled) {
92
- ruleOffenses = (rule as ParserRule).check(parseResult, context)
540
+ return b.location.start.column - a.location.start.column
541
+ })
542
+
543
+ for (const offense of sortedSourceOffenses) {
544
+ const RuleClass = this.rules.find(rule => new rule().name === offense.rule)
545
+
546
+ if (!RuleClass) {
547
+ unfixed.push(offense)
548
+ continue
549
+ }
550
+
551
+ const rule = new RuleClass() as SourceRule
552
+
553
+ if (!rule.autofix) {
554
+ unfixed.push(offense)
555
+ continue
556
+ }
557
+
558
+ const correctedSource = rule.autofix(offense, currentSource, context)
559
+
560
+ if (correctedSource) {
561
+ currentSource = correctedSource
562
+ fixed.push(offense)
93
563
  } else {
94
- ruleOffenses = []
564
+ unfixed.push(offense)
95
565
  }
96
566
  }
97
-
98
- this.offenses.push(...ruleOffenses)
99
567
  }
100
568
 
101
- const errors = this.offenses.filter(offense => offense.severity === "error").length
102
- const warnings = this.offenses.filter(offense => offense.severity === "warning").length
103
-
104
569
  return {
105
- offenses: this.offenses,
106
- errors,
107
- warnings
570
+ source: currentSource,
571
+ fixed,
572
+ unfixed
108
573
  }
109
574
  }
110
575
  }
package/src/loader.ts ADDED
@@ -0,0 +1,30 @@
1
+ export * from "./index.js"
2
+
3
+ export { CustomRuleLoader } from "./custom-rule-loader.js"
4
+ export type { CustomRuleLoaderOptions } from "./custom-rule-loader.js"
5
+
6
+ import { CustomRuleLoader } from "./custom-rule-loader.js"
7
+ import type { RuleClass } from "./types.js"
8
+
9
+ /**
10
+ * Loads custom rules from the filesystem.
11
+ * Only available in Node.js environments.
12
+ */
13
+ export async function loadCustomRules(options?: {
14
+ baseDir?: string
15
+ patterns?: string[]
16
+ silent?: boolean
17
+ }): Promise<{
18
+ rules: RuleClass[]
19
+ ruleInfo: Array<{ name: string, path: string }>
20
+ warnings: string[]
21
+ }> {
22
+ const loader = new CustomRuleLoader(options)
23
+ const { rules: customRules, ruleInfo, duplicateWarnings } = await loader.loadRulesWithInfo()
24
+
25
+ return {
26
+ rules: customRules,
27
+ ruleInfo,
28
+ warnings: duplicateWarnings
29
+ }
30
+ }
@@ -0,0 +1,73 @@
1
+ import { BaseRuleVisitor } from "./rule-utils.js"
2
+ import { ParserRule, BaseAutofixContext, Mutable } from "../types.js"
3
+
4
+ import type { UnboundLintOffense, LintOffense, LintContext, FullRuleConfig } from "../types.js"
5
+ import type { ParseResult, ERBContentNode } from "@herb-tools/core"
6
+
7
+ interface ERBCommentSyntaxAutofixContext extends BaseAutofixContext {
8
+ node: Mutable<ERBContentNode>
9
+ }
10
+
11
+ class ERBCommentSyntaxVisitor extends BaseRuleVisitor<ERBCommentSyntaxAutofixContext> {
12
+ visitERBContentNode(node: ERBContentNode): void {
13
+ const content = node.content?.value || ""
14
+
15
+ if (content.match(/^ +#/)) {
16
+ const openingTag = node.tag_opening?.value
17
+
18
+ if (content.includes("herb:disable")) {
19
+ this.addOffense(
20
+ `Use \`<%#\` instead of \`${openingTag} #\` for \`herb:disable\` directives. Herb directives only work with ERB comment syntax (\`<%# ... %>\`).`,
21
+ node.location,
22
+ { node }
23
+ )
24
+ } else {
25
+ this.addOffense(
26
+ `Use \`<%#\` instead of \`${openingTag} #\`. Ruby comments immediately after ERB tags can cause parsing issues.`,
27
+ node.location,
28
+ { node }
29
+ )
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ export class ERBCommentSyntax extends ParserRule<ERBCommentSyntaxAutofixContext> {
36
+ static autocorrectable = true
37
+ name = "erb-comment-syntax"
38
+
39
+ get defaultConfig(): FullRuleConfig {
40
+ return {
41
+ enabled: true,
42
+ severity: "error"
43
+ }
44
+ }
45
+
46
+ check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<ERBCommentSyntaxAutofixContext>[] {
47
+ const visitor = new ERBCommentSyntaxVisitor(this.name, context)
48
+
49
+ visitor.visit(result.value)
50
+
51
+ return visitor.offenses
52
+ }
53
+
54
+ autofix(offense: LintOffense<ERBCommentSyntaxAutofixContext>, result: ParseResult, _context?: Partial<LintContext>): ParseResult | null {
55
+ if (!offense.autofixContext) return null
56
+
57
+ const { node } = offense.autofixContext
58
+
59
+ if (!node.tag_opening) return null
60
+ if (!node.content) return null
61
+
62
+ node.tag_opening.value = "<%#"
63
+
64
+ const content = node.content.value
65
+ const match = content.match(/^ +(#)/)
66
+
67
+ if (match) {
68
+ node.content.value = content.substring(match[0].length)
69
+ }
70
+
71
+ return result
72
+ }
73
+ }