From f5c7269c1afce2d9a828451f292131227d244049 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Wed, 22 Jul 2026 18:10:25 -0300 Subject: [PATCH 1/6] Linter: Add `erb-prefer-pluralize-helper` rule --- .../packages/linter/docs/rules/README.md | 1 + .../docs/rules/erb-prefer-pluralize-helper.md | 40 ++++++++++ javascript/packages/linter/src/rules.ts | 2 + .../src/rules/erb-prefer-pluralize-helper.ts | 74 ++++++++++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../rules/erb-prefer-pluralize-helper.test.ts | 75 +++++++++++++++++++ 6 files changed, 193 insertions(+) create mode 100644 javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md create mode 100644 javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts create mode 100644 javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index fa28fb9ab..82f9ea3f7 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -71,6 +71,7 @@ This page contains documentation for all Herb Linter rules. - [`erb-prefer-each-over-map`](./erb-prefer-each-over-map.md) - Prefer `each` over `map` when the result is discarded - [`erb-prefer-explicit-conditionals`](./erb-prefer-explicit-conditionals.md) - Prefer explicit `if`/`unless` blocks over inline conditions in ERB output tags - [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `` with ERB expressions +- [`erb-prefer-pluralize-helper`](./erb-prefer-pluralize-helper.md) - Prefer the `pluralize` helper over `String#pluralize` for counts - [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character. - [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags - [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax. diff --git a/javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md b/javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md new file mode 100644 index 000000000..80abbf98e --- /dev/null +++ b/javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md @@ -0,0 +1,40 @@ +# Linter Rule: Prefer the `pluralize` helper over `String#pluralize` for counts + +**Rule:** `erb-prefer-pluralize-helper` + +## Description + +Prefer Rails' `ActionView::Helpers::TextHelper#pluralize` helper over calling `String#pluralize` with a count in ERB templates. + +## Rationale + +Both `pluralize("Alias", count)` and `"Alias".pluralize(count)` inflect a word based on a count, but they behave differently. The `pluralize` helper prepends the count to the resulting string (e.g. `pluralize(2, "person")` returns `"2 people"`), which is almost always what you want when rendering a count next to a noun. It also handles the `1`/singular case correctly and reads naturally in a template. + +Reaching for `String#pluralize(count)` usually means the count is rendered separately, leading to duplicated output like `<%= aliases.size %> <%= "Alias".pluralize(aliases.size) %>`. Consolidating on the `pluralize` helper keeps the count and the noun together, avoids the extra output tag, and makes the intent clearer. + +## Examples + +### ✅ Good + +```erb +<%= pluralize("Known Alias", aliases.size) %> +``` + +```erb +Known <%= pluralize("Alias", aliases.size) %> +``` + +### 🚫 Bad + +```erb +<%= aliases.size %><%= "Known Alias".pluralize(aliases.size) %> +``` + +```erb +<%= aliases.size %> Known <%= "Alias".pluralize(aliases.size) %> +``` + +## References + +- [`ActionView::Helpers::TextHelper#pluralize`](https://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-pluralize) +- [`String#pluralize`](https://api.rubyonrails.org/classes/String.html#method-i-pluralize) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index f3efd6edf..4c13c5825 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -60,6 +60,7 @@ import { ERBPreferDoEndBlocksRule } from "./rules/erb-prefer-do-end-blocks.js" import { ERBPreferEachOverMapRule } from "./rules/erb-prefer-each-over-map.js" import { ERBPreferExplicitConditionalsRule } from "./rules/erb-prefer-explicit-conditionals.js" import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js" +import { ERBPreferPluralizeHelperRule } from "./rules/erb-prefer-pluralize-helper.js" import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js" import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js" import { ERBRightTrimRule } from "./rules/erb-right-trim.js" @@ -184,6 +185,7 @@ export const rules: RuleClass[] = [ ERBPreferEachOverMapRule, ERBPreferExplicitConditionalsRule, ERBPreferImageTagHelperRule, + ERBPreferPluralizeHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, diff --git a/javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts b/javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts new file mode 100644 index 000000000..23cdfc0ab --- /dev/null +++ b/javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts @@ -0,0 +1,74 @@ +import { ParserRule } from "../types.js" +import { PrismVisitor, isPrismNodeType } from "@herb-tools/core" + +import { locationFromOffset } from "./rule-utils.js" + +import type { ParseResult, ParserOptions, PrismNode } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +class StringPluralizeCallCollector extends PrismVisitor { + public readonly calls: PrismNode[] = [] + + visitCallNode(node: PrismNode): void { + if (this.isStringPluralizeWithCount(node)) { + this.calls.push(node) + } + + this.visitChildNodes(node) + } + + private isStringPluralizeWithCount(node: PrismNode): boolean { + if (node.name !== "pluralize") return false + + const receiver = node.receiver + + if (!isPrismNodeType(receiver, "StringNode") && !isPrismNodeType(receiver, "InterpolatedStringNode")) { + return false + } + + const args = node.arguments_?.arguments_ + + return Array.isArray(args) && args.length > 0 + } +} + +export class ERBPreferPluralizeHelperRule extends ParserRule { + static ruleName = "erb-prefer-pluralize-helper" + static introducedIn = this.version("unreleased") + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + prism_program: true, + } + } + + check(result: ParseResult, _context?: Partial): UnboundLintOffense[] { + const source = result.value.source + const prismNode = result.value.prismNode + + if (!prismNode || !source) return [] + + const collector = new StringPluralizeCallCollector() + collector.visit(prismNode) + + const slice = (node: PrismNode) => + source.substring(node.location.startOffset, node.location.startOffset + node.location.length) + + return collector.calls.map(call => { + const location = locationFromOffset(source, call.location.startOffset, call.location.length) + const suggestion = `pluralize(${slice(call.receiver)}, ${slice(call.arguments_)})` + + return this.createOffense( + `Prefer the \`pluralize\` helper over \`String#pluralize\` for counts. Use \`<%= ${suggestion} %>\` instead.`, + location, + ) + }) + } +} diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 0e1ee7eeb..2996ea0e5 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -63,6 +63,7 @@ export * from "./erb-prefer-do-end-blocks.js" export * from "./erb-prefer-each-over-map.js" export * from "./erb-prefer-explicit-conditionals.js" export * from "./erb-prefer-image-tag-helper.js" +export * from "./erb-prefer-pluralize-helper.js" export * from "./erb-require-trailing-newline.js" export * from "./erb-require-whitespace-inside-tags.js" export * from "./erb-right-trim.js" diff --git a/javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts b/javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts new file mode 100644 index 000000000..4a2e52962 --- /dev/null +++ b/javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts @@ -0,0 +1,75 @@ +import dedent from "dedent" +import { describe, test } from "vitest" + +import { ERBPreferPluralizeHelperRule } from "../../src/rules/erb-prefer-pluralize-helper.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ERBPreferPluralizeHelperRule) + +describe("ERBPreferPluralizeHelperRule", () => { + describe("valid cases", () => { + test("passes for the pluralize helper", () => { + expectNoOffenses(dedent` + <%= pluralize("Known Alias", aliases.size) %> + `) + }) + + test("passes for the pluralize helper mixed with text", () => { + expectNoOffenses(dedent` + Known <%= pluralize("Alias", aliases.size) %> + `) + }) + + test("passes for String#pluralize without a count", () => { + expectNoOffenses(dedent` + <%= "Alias".pluralize %> + `) + }) + + test("passes for pluralize on a non-string receiver", () => { + expectNoOffenses(dedent` + <%= model.pluralize(count) %> + `) + }) + + test("passes for an unrelated method call on a string", () => { + expectNoOffenses(dedent` + <%= "Alias".upcase %> + `) + }) + }) + + describe("invalid cases", () => { + test("fails for String#pluralize with a count", () => { + expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", aliases.size) %>` instead.') + + assertOffenses(dedent` + <%= "Alias".pluralize(aliases.size) %> + `) + }) + + test("fails for String#pluralize with a count mixed with text", () => { + expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", aliases.size) %>` instead.') + + assertOffenses(dedent` + <%= aliases.size %> Known <%= "Alias".pluralize(aliases.size) %> + `) + }) + + test("fails for String#pluralize with an integer count", () => { + expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("person", 2) %>` instead.') + + assertOffenses(dedent` + <%= "person".pluralize(2) %> + `) + }) + + test("fails for String#pluralize in a silent tag", () => { + expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", count) %>` instead.') + + assertOffenses(dedent` + <% "Alias".pluralize(count) %> + `) + }) + }) +}) From 028c09c57268c6c2b5089f09c9c19c26edad05ea Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Wed, 22 Jul 2026 22:27:43 -0300 Subject: [PATCH 2/6] Linter: Rename `erb-prefer-pluralize-helper` to `actionview-prefer-pluralize-helper` --- javascript/packages/linter/docs/rules/README.md | 3 +-- ...lize-helper.md => actionview-prefer-pluralize-helper.md} | 2 +- javascript/packages/linter/src/rules.ts | 6 ++---- ...lize-helper.ts => actionview-prefer-pluralize-helper.ts} | 4 ++-- javascript/packages/linter/src/rules/index.ts | 3 +-- ...r.test.ts => actionview-prefer-pluralize-helper.test.ts} | 6 +++--- 6 files changed, 10 insertions(+), 14 deletions(-) rename javascript/packages/linter/docs/rules/{erb-prefer-pluralize-helper.md => actionview-prefer-pluralize-helper.md} (97%) rename javascript/packages/linter/src/rules/{erb-prefer-pluralize-helper.ts => actionview-prefer-pluralize-helper.ts} (94%) rename javascript/packages/linter/test/rules/{erb-prefer-pluralize-helper.test.ts => actionview-prefer-pluralize-helper.test.ts} (91%) diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 82f9ea3f7..42f2985c5 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -27,7 +27,7 @@ This page contains documentation for all Herb Linter rules. - [`actionview-no-unnecessary-tag-attributes`](./actionview-no-unnecessary-tag-attributes.md) - Disallow unnecessary attributes on Action View tag helpers - [`actionview-no-unused-strict-locals`](./actionview-no-unused-strict-locals.md) - Disallow strict locals that are never used in the partial - [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements -- [`actionview-prefer-collection-render`](./actionview-prefer-collection-render.md) - Prefer collection rendering over rendering a partial in a loop +- [`actionview-prefer-pluralize-helper`](./actionview-prefer-pluralize-helper.md) - Prefer the `pluralize` helper over `String#pluralize` for counts - [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after. - [`actionview-strict-locals-partial-only`](./actionview-strict-locals-partial-only.md) - Only allow strict local definitions in partial files. @@ -71,7 +71,6 @@ This page contains documentation for all Herb Linter rules. - [`erb-prefer-each-over-map`](./erb-prefer-each-over-map.md) - Prefer `each` over `map` when the result is discarded - [`erb-prefer-explicit-conditionals`](./erb-prefer-explicit-conditionals.md) - Prefer explicit `if`/`unless` blocks over inline conditions in ERB output tags - [`erb-prefer-image-tag-helper`](./erb-prefer-image-tag-helper.md) - Prefer `image_tag` helper over `` with ERB expressions -- [`erb-prefer-pluralize-helper`](./erb-prefer-pluralize-helper.md) - Prefer the `pluralize` helper over `String#pluralize` for counts - [`erb-require-trailing-newline`](./erb-require-trailing-newline.md) - Enforces that all HTML+ERB template files end with exactly one trailing newline character. - [`erb-require-whitespace-inside-tags`](./erb-require-whitespace-inside-tags.md) - Requires whitespace around ERB tags - [`erb-right-trim`](./erb-right-trim.md) - Enforce consistent right-trimming syntax. diff --git a/javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md b/javascript/packages/linter/docs/rules/actionview-prefer-pluralize-helper.md similarity index 97% rename from javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md rename to javascript/packages/linter/docs/rules/actionview-prefer-pluralize-helper.md index 80abbf98e..e4144f114 100644 --- a/javascript/packages/linter/docs/rules/erb-prefer-pluralize-helper.md +++ b/javascript/packages/linter/docs/rules/actionview-prefer-pluralize-helper.md @@ -1,6 +1,6 @@ # Linter Rule: Prefer the `pluralize` helper over `String#pluralize` for counts -**Rule:** `erb-prefer-pluralize-helper` +**Rule:** `actionview-prefer-pluralize-helper` ## Description diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 4c13c5825..c5f25dc7d 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -19,7 +19,7 @@ import { ActionViewNoUnnecessaryHTMLSafeRule } from "./rules/actionview-no-unnec import { ActionViewNoUnnecessaryTagAttributesRule } from "./rules/actionview-no-unnecessary-tag-attributes.js" import { ActionViewNoUnusedStrictLocalsRule } from "./rules/actionview-no-unused-strict-locals.js" import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js" -import { ActionViewPreferCollectionRenderRule } from "./rules/actionview-prefer-collection-render.js" +import { ActionViewPreferPluralizeHelperRule } from "./rules/actionview-prefer-pluralize-helper.js" import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js" import { ActionViewStrictLocalsPartialOnlyRule } from "./rules/actionview-strict-locals-partial-only.js" @@ -60,7 +60,6 @@ import { ERBPreferDoEndBlocksRule } from "./rules/erb-prefer-do-end-blocks.js" import { ERBPreferEachOverMapRule } from "./rules/erb-prefer-each-over-map.js" import { ERBPreferExplicitConditionalsRule } from "./rules/erb-prefer-explicit-conditionals.js" import { ERBPreferImageTagHelperRule } from "./rules/erb-prefer-image-tag-helper.js" -import { ERBPreferPluralizeHelperRule } from "./rules/erb-prefer-pluralize-helper.js" import { ERBRequireTrailingNewlineRule } from "./rules/erb-require-trailing-newline.js" import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js" import { ERBRightTrimRule } from "./rules/erb-right-trim.js" @@ -144,7 +143,7 @@ export const rules: RuleClass[] = [ ActionViewNoUnnecessaryTagAttributesRule, ActionViewNoUnusedStrictLocalsRule, ActionViewNoVoidElementContentRule, - ActionViewPreferCollectionRenderRule, + ActionViewPreferPluralizeHelperRule, ActionViewStrictLocalsFirstLineRule, ActionViewStrictLocalsPartialOnlyRule, @@ -185,7 +184,6 @@ export const rules: RuleClass[] = [ ERBPreferEachOverMapRule, ERBPreferExplicitConditionalsRule, ERBPreferImageTagHelperRule, - ERBPreferPluralizeHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, diff --git a/javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts similarity index 94% rename from javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts rename to javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts index 23cdfc0ab..681bfd1e1 100644 --- a/javascript/packages/linter/src/rules/erb-prefer-pluralize-helper.ts +++ b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts @@ -32,8 +32,8 @@ class StringPluralizeCallCollector extends PrismVisitor { } } -export class ERBPreferPluralizeHelperRule extends ParserRule { - static ruleName = "erb-prefer-pluralize-helper" +export class ActionViewPreferPluralizeHelperRule extends ParserRule { + static ruleName = "actionview-prefer-pluralize-helper" static introducedIn = this.version("unreleased") get defaultConfig(): FullRuleConfig { diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 2996ea0e5..0262417c3 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -24,7 +24,7 @@ export * from "./actionview-no-unnecessary-html-safe.js" export * from "./actionview-no-unnecessary-tag-attributes.js" export * from "./actionview-no-unused-strict-locals.js" export * from "./actionview-no-void-element-content.js" -export * from "./actionview-prefer-collection-render.js" +export * from "./actionview-prefer-pluralize-helper.js" export * from "./actionview-strict-locals-first-line.js" export * from "./actionview-strict-locals-partial-only.js" @@ -63,7 +63,6 @@ export * from "./erb-prefer-do-end-blocks.js" export * from "./erb-prefer-each-over-map.js" export * from "./erb-prefer-explicit-conditionals.js" export * from "./erb-prefer-image-tag-helper.js" -export * from "./erb-prefer-pluralize-helper.js" export * from "./erb-require-trailing-newline.js" export * from "./erb-require-whitespace-inside-tags.js" export * from "./erb-right-trim.js" diff --git a/javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts similarity index 91% rename from javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts rename to javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts index 4a2e52962..2eeaa9b8d 100644 --- a/javascript/packages/linter/test/rules/erb-prefer-pluralize-helper.test.ts +++ b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts @@ -1,12 +1,12 @@ import dedent from "dedent" import { describe, test } from "vitest" -import { ERBPreferPluralizeHelperRule } from "../../src/rules/erb-prefer-pluralize-helper.js" +import { ActionViewPreferPluralizeHelperRule } from "../../src/rules/actionview-prefer-pluralize-helper.js" import { createLinterTest } from "../helpers/linter-test-helper.js" -const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ERBPreferPluralizeHelperRule) +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ActionViewPreferPluralizeHelperRule) -describe("ERBPreferPluralizeHelperRule", () => { +describe("ActionViewPreferPluralizeHelperRule", () => { describe("valid cases", () => { test("passes for the pluralize helper", () => { expectNoOffenses(dedent` From ac92cac8733865dddf1e91a3cad8d6adf19a9c3c Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Mon, 3 Aug 2026 09:47:03 -0300 Subject: [PATCH 3/6] chore: update import at actionview-prefer-pluralize-helper.ts --- javascript/packages/linter/docs/rules/README.md | 1 + javascript/packages/linter/src/rules.ts | 2 ++ .../linter/src/rules/actionview-prefer-pluralize-helper.ts | 6 ++---- javascript/packages/linter/src/rules/index.ts | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 42f2985c5..29e13dcb6 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -27,6 +27,7 @@ This page contains documentation for all Herb Linter rules. - [`actionview-no-unnecessary-tag-attributes`](./actionview-no-unnecessary-tag-attributes.md) - Disallow unnecessary attributes on Action View tag helpers - [`actionview-no-unused-strict-locals`](./actionview-no-unused-strict-locals.md) - Disallow strict locals that are never used in the partial - [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements +- [`actionview-prefer-collection-render`](./actionview-prefer-collection-render.md) - Prefer collection rendering over rendering a partial in a loop - [`actionview-prefer-pluralize-helper`](./actionview-prefer-pluralize-helper.md) - Prefer the `pluralize` helper over `String#pluralize` for counts - [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after. - [`actionview-strict-locals-partial-only`](./actionview-strict-locals-partial-only.md) - Only allow strict local definitions in partial files. diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index c5f25dc7d..22657ce58 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -19,6 +19,7 @@ import { ActionViewNoUnnecessaryHTMLSafeRule } from "./rules/actionview-no-unnec import { ActionViewNoUnnecessaryTagAttributesRule } from "./rules/actionview-no-unnecessary-tag-attributes.js" import { ActionViewNoUnusedStrictLocalsRule } from "./rules/actionview-no-unused-strict-locals.js" import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js" +import { ActionViewPreferCollectionRenderRule } from "./rules/actionview-prefer-collection-render.js" import { ActionViewPreferPluralizeHelperRule } from "./rules/actionview-prefer-pluralize-helper.js" import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js" import { ActionViewStrictLocalsPartialOnlyRule } from "./rules/actionview-strict-locals-partial-only.js" @@ -143,6 +144,7 @@ export const rules: RuleClass[] = [ ActionViewNoUnnecessaryTagAttributesRule, ActionViewNoUnusedStrictLocalsRule, ActionViewNoVoidElementContentRule, + ActionViewPreferCollectionRenderRule, ActionViewPreferPluralizeHelperRule, ActionViewStrictLocalsFirstLineRule, ActionViewStrictLocalsPartialOnlyRule, diff --git a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts index 681bfd1e1..69b8919fd 100644 --- a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts +++ b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts @@ -1,7 +1,5 @@ import { ParserRule } from "../types.js" -import { PrismVisitor, isPrismNodeType } from "@herb-tools/core" - -import { locationFromOffset } from "./rule-utils.js" +import { PrismVisitor, isPrismNodeType, locationFromByteOffset } from "@herb-tools/core" import type { ParseResult, ParserOptions, PrismNode } from "@herb-tools/core" import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" @@ -62,7 +60,7 @@ export class ActionViewPreferPluralizeHelperRule extends ParserRule { source.substring(node.location.startOffset, node.location.startOffset + node.location.length) return collector.calls.map(call => { - const location = locationFromOffset(source, call.location.startOffset, call.location.length) + const location = locationFromByteOffset(source, call.location.startOffset, call.location.length) const suggestion = `pluralize(${slice(call.receiver)}, ${slice(call.arguments_)})` return this.createOffense( diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 0262417c3..5354a2211 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -24,6 +24,7 @@ export * from "./actionview-no-unnecessary-html-safe.js" export * from "./actionview-no-unnecessary-tag-attributes.js" export * from "./actionview-no-unused-strict-locals.js" export * from "./actionview-no-void-element-content.js" +export * from "./actionview-prefer-collection-render.js" export * from "./actionview-prefer-pluralize-helper.js" export * from "./actionview-strict-locals-first-line.js" export * from "./actionview-strict-locals-partial-only.js" From 38317f2106fcefb1c2e80f734be57b402180a984 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Tue, 4 Aug 2026 10:37:39 -0300 Subject: [PATCH 4/6] Refine pluralize helper detection --- .../actionview-prefer-pluralize-helper.ts | 201 +++++++++++++++--- ...actionview-prefer-pluralize-helper.test.ts | 90 ++++++-- 2 files changed, 239 insertions(+), 52 deletions(-) diff --git a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts index 69b8919fd..12d0a727f 100644 --- a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts +++ b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts @@ -1,32 +1,170 @@ import { ParserRule } from "../types.js" -import { PrismVisitor, isPrismNodeType, locationFromByteOffset } from "@herb-tools/core" +import { BaseRuleVisitor } from "./rule-utils.js" +import { + isERBContentNode, + isERBOutputNode, + isHTMLTextNode, + isLiteralNode, + isPrismNodeType, + locationFromByteOffset, + substringFromByteOffset, +} from "@herb-tools/core" + +import type { + DocumentNode, + ERBContentNode, + HTMLElementNode, + Node, + ParseResult, + ParserOptions, + PrismNode, +} from "@herb-tools/core" +import type { + UnboundLintOffense, + LintContext, + FullRuleConfig, +} from "../types.js" + +const COUNT_METHODS = new Set(["length", "size", "count"]) + +function singleExpression(node: PrismNode | null): PrismNode | null { + if (!node) return null + + if (isPrismNodeType(node, "ProgramNode")) { + const body = node.statements?.body + return Array.isArray(body) && body.length === 1 ? body[0] : null + } -import type { ParseResult, ParserOptions, PrismNode } from "@herb-tools/core" -import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + if (isPrismNodeType(node, "StatementsNode")) { + return node.body.length === 1 ? node.body[0] : null + } -class StringPluralizeCallCollector extends PrismVisitor { - public readonly calls: PrismNode[] = [] + return node +} - visitCallNode(node: PrismNode): void { - if (this.isStringPluralizeWithCount(node)) { - this.calls.push(node) - } +function countExpression(node: ERBContentNode): PrismNode | null { + const expression = singleExpression(node.prismNode) + + if (!isPrismNodeType(expression, "CallNode")) return null + if (!expression.receiver || !COUNT_METHODS.has(expression.name)) return null + + return expression +} + +function stringPluralizeCall(node: ERBContentNode): PrismNode | null { + const expression = singleExpression(node.prismNode) + + if (!isPrismNodeType(expression, "CallNode")) return null + if (expression.name !== "pluralize") return null + if (!isPrismNodeType(expression.receiver, "StringNode")) return null + + const args = expression.arguments_?.arguments_ + if (!Array.isArray(args) || args.length === 0) return null + return expression +} + +function prismFingerprint(node: PrismNode): string { + return JSON.stringify(node, (key, value) => { + if (key === "location" || key.endsWith("Loc")) return undefined + // Prism marks a call used as the value of a program differently from the + // same call nested in an argument. That contextual flag is not semantic. + if (key === "flags" && typeof value === "number") return value & ~1 + return value + }) +} + +function staticText(node: Node): string | null { + if (isLiteralNode(node) || isHTMLTextNode(node)) return node.content ?? "" + return null +} + +class ActionViewPreferPluralizeHelperVisitor extends BaseRuleVisitor { + constructor( + ruleName: string, + context: Partial | undefined, + private readonly source: string, + ) { + super(ruleName, context) + } + + visitDocumentNode(node: DocumentNode): void { + this.checkSiblings(node.children) this.visitChildNodes(node) } - private isStringPluralizeWithCount(node: PrismNode): boolean { - if (node.name !== "pluralize") return false + visitHTMLElementNode(node: HTMLElementNode): void { + this.checkSiblings(node.body) + this.visitChildNodes(node) + } - const receiver = node.receiver + private checkSiblings(nodes: Node[]): void { + for (let index = 0; index < nodes.length; index++) { + const firstERB = nodes[index] + if (!isERBContentNode(firstERB) || !isERBOutputNode(firstERB)) continue - if (!isPrismNodeType(receiver, "StringNode") && !isPrismNodeType(receiver, "InterpolatedStringNode")) { - return false + const count = countExpression(firstERB) + if (!count) continue + + let nextIndex = index + 1 + let interveningText = "" + + while (nextIndex < nodes.length) { + const text = staticText(nodes[nextIndex]) + if (text === null) break + + interveningText += text + nextIndex++ + } + + const secondERB = nodes[nextIndex] + if (!isERBContentNode(secondERB) || !isERBOutputNode(secondERB)) continue + + const pluralize = stringPluralizeCall(secondERB) + if (!pluralize) continue + + const pluralizeCount = pluralize.arguments_.arguments_[0] + if (prismFingerprint(count) !== prismFingerprint(pluralizeCount)) continue + + this.addPluralizeOffense(count, pluralize, interveningText) } + } - const args = node.arguments_?.arguments_ + private addPluralizeOffense( + count: PrismNode, + pluralize: PrismNode, + interveningText: string, + ): void { + const slice = (node: PrismNode) => + substringFromByteOffset( + this.source, + node.location.startOffset, + node.location.length, + ) - return Array.isArray(args) && args.length > 0 + let singular = slice(pluralize.receiver) + const prefix = interveningText.trim() + + if (prefix) { + const content = substringFromByteOffset( + this.source, + pluralize.receiver.contentLoc.startOffset, + pluralize.receiver.contentLoc.length, + ) + singular = JSON.stringify(`${prefix} ${content}`) + } + + const suggestion = `pluralize(${slice(count)}, ${singular})` + const location = locationFromByteOffset( + this.source, + pluralize.location.startOffset, + pluralize.location.length, + ) + + this.addOffense( + `Prefer the \`pluralize\` helper over separate count and \`String#pluralize\` output. Use \`<%= ${suggestion} %>\` instead.`, + location, + ) } } @@ -43,30 +181,25 @@ export class ActionViewPreferPluralizeHelperRule extends ParserRule { get parserOptions(): Partial { return { + prism_nodes: true, prism_program: true, } } - check(result: ParseResult, _context?: Partial): UnboundLintOffense[] { + check( + result: ParseResult, + context?: Partial, + ): UnboundLintOffense[] { const source = result.value.source - const prismNode = result.value.prismNode - - if (!prismNode || !source) return [] + if (!source) return [] - const collector = new StringPluralizeCallCollector() - collector.visit(prismNode) + const visitor = new ActionViewPreferPluralizeHelperVisitor( + this.ruleName, + context, + source, + ) + visitor.visit(result.value) - const slice = (node: PrismNode) => - source.substring(node.location.startOffset, node.location.startOffset + node.location.length) - - return collector.calls.map(call => { - const location = locationFromByteOffset(source, call.location.startOffset, call.location.length) - const suggestion = `pluralize(${slice(call.receiver)}, ${slice(call.arguments_)})` - - return this.createOffense( - `Prefer the \`pluralize\` helper over \`String#pluralize\` for counts. Use \`<%= ${suggestion} %>\` instead.`, - location, - ) - }) + return visitor.offenses } } diff --git a/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts index 2eeaa9b8d..c9cb9e2b2 100644 --- a/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts +++ b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts @@ -4,19 +4,45 @@ import { describe, test } from "vitest" import { ActionViewPreferPluralizeHelperRule } from "../../src/rules/actionview-prefer-pluralize-helper.js" import { createLinterTest } from "../helpers/linter-test-helper.js" -const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(ActionViewPreferPluralizeHelperRule) +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest( + ActionViewPreferPluralizeHelperRule, +) describe("ActionViewPreferPluralizeHelperRule", () => { describe("valid cases", () => { test("passes for the pluralize helper", () => { expectNoOffenses(dedent` - <%= pluralize("Known Alias", aliases.size) %> + <%= pluralize(aliases.size, "Known Alias") %> `) }) test("passes for the pluralize helper mixed with text", () => { expectNoOffenses(dedent` - Known <%= pluralize("Alias", aliases.size) %> + Known <%= pluralize(aliases.size, "Alias") %> + `) + }) + + test("passes for an isolated String#pluralize call", () => { + expectNoOffenses(dedent` + <%= "Known Alias".pluralize(aliases.size) %> + `) + }) + + test("passes when the count receivers differ", () => { + expectNoOffenses(dedent` + <%= aliases.size %> <%= "Known Alias".pluralize(other.size) %> + `) + }) + + test("passes when the count methods differ", () => { + expectNoOffenses(dedent` + <%= aliases.count %> <%= "Known Alias".pluralize(aliases.size) %> + `) + }) + + test("passes when pluralize has a non-string receiver", () => { + expectNoOffenses(dedent` + <%= aliases.size %> <%= variable.pluralize(aliases.size) %> `) }) @@ -26,49 +52,77 @@ describe("ActionViewPreferPluralizeHelperRule", () => { `) }) - test("passes for pluralize on a non-string receiver", () => { + test("does not match across another executable ERB node", () => { expectNoOffenses(dedent` - <%= model.pluralize(count) %> + <%= aliases.size %><% track(aliases) %><%= "Known Alias".pluralize(aliases.size) %> `) }) - test("passes for an unrelated method call on a string", () => { + test("does not match across an HTML element", () => { expectNoOffenses(dedent` - <%= "Alias".upcase %> + <%= aliases.size %>Known<%= "Alias".pluralize(aliases.size) %> `) }) }) describe("invalid cases", () => { - test("fails for String#pluralize with a count", () => { - expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", aliases.size) %>` instead.') + test("fails with no content between the paired output tags", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(aliases.size, "Known Alias") %>` instead.', + ) assertOffenses(dedent` - <%= "Alias".pluralize(aliases.size) %> + <%= aliases.size %><%= "Known Alias".pluralize(aliases.size) %> `) }) - test("fails for String#pluralize with a count mixed with text", () => { - expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", aliases.size) %>` instead.') + test("fails with whitespace between the paired output tags", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(aliases.size, "Known Alias") %>` instead.', + ) + + assertOffenses(dedent` + <%= aliases.size %> <%= "Known Alias".pluralize(aliases.size) %> + `) + }) + + test("includes intervening literal text in the suggested singular", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(aliases.size, "Known Alias") %>` instead.', + ) assertOffenses(dedent` <%= aliases.size %> Known <%= "Alias".pluralize(aliases.size) %> `) }) - test("fails for String#pluralize with an integer count", () => { - expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("person", 2) %>` instead.') + test("supports length", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(users.length, "User") %>` instead.', + ) + + assertOffenses(dedent` + <%= users.length %> <%= "User".pluralize(users.length) %> + `) + }) + + test("supports count", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(records.count, "Record") %>` instead.', + ) assertOffenses(dedent` - <%= "person".pluralize(2) %> + <%= records.count %> <%= "Record".pluralize(records.count) %> `) }) - test("fails for String#pluralize in a silent tag", () => { - expectWarning('Prefer the `pluralize` helper over `String#pluralize` for counts. Use `<%= pluralize("Alias", count) %>` instead.') + test("matches structurally equivalent nested count expressions", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(account.aliases(true).size, "Alias") %>` instead.', + ) assertOffenses(dedent` - <% "Alias".pluralize(count) %> + <%= account.aliases(true).size %> <%= "Alias".pluralize(account.aliases(true).size) %> `) }) }) From b4b9b1877ced737d961f42eb05e64b6757d192e7 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Tue, 4 Aug 2026 10:59:20 -0300 Subject: [PATCH 5/6] Linter: Support interpolated receivers in `actionview-prefer-pluralize-helper` --- .../actionview-prefer-pluralize-helper.ts | 32 +++++++++++------ ...actionview-prefer-pluralize-helper.test.ts | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts index 12d0a727f..bb4a6806a 100644 --- a/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts +++ b/javascript/packages/linter/src/rules/actionview-prefer-pluralize-helper.ts @@ -56,10 +56,15 @@ function stringPluralizeCall(node: ERBContentNode): PrismNode | null { if (!isPrismNodeType(expression, "CallNode")) return null if (expression.name !== "pluralize") return null - if (!isPrismNodeType(expression.receiver, "StringNode")) return null + if ( + !isPrismNodeType(expression.receiver, "StringNode") && + !isPrismNodeType(expression.receiver, "InterpolatedStringNode") + ) { + return null + } const args = expression.arguments_?.arguments_ - if (!Array.isArray(args) || args.length === 0) return null + if (!Array.isArray(args) || args.length !== 1) return null return expression } @@ -143,15 +148,22 @@ class ActionViewPreferPluralizeHelperVisitor extends BaseRuleVisitor { ) let singular = slice(pluralize.receiver) - const prefix = interveningText.trim() - if (prefix) { - const content = substringFromByteOffset( - this.source, - pluralize.receiver.contentLoc.startOffset, - pluralize.receiver.contentLoc.length, - ) - singular = JSON.stringify(`${prefix} ${content}`) + if (interveningText.trim()) { + const canRepresentLiteralText = + interveningText.startsWith(" ") && + isPrismNodeType(pluralize.receiver, "StringNode") + + if (!canRepresentLiteralText) { + singular = "singular" + } else { + const content = substringFromByteOffset( + this.source, + pluralize.receiver.contentLoc.startOffset, + pluralize.receiver.contentLoc.length, + ) + singular = JSON.stringify(`${interveningText.slice(1)}${content}`) + } } const suggestion = `pluralize(${slice(count)}, ${singular})` diff --git a/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts index c9cb9e2b2..bb858bb6f 100644 --- a/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts +++ b/javascript/packages/linter/test/rules/actionview-prefer-pluralize-helper.test.ts @@ -52,6 +52,12 @@ describe("ActionViewPreferPluralizeHelperRule", () => { `) }) + test("passes when String#pluralize has an additional locale argument", () => { + expectNoOffenses(dedent` + <%= users.size %> <%= "User".pluralize(users.size, :fr) %> + `) + }) + test("does not match across another executable ERB node", () => { expectNoOffenses(dedent` <%= aliases.size %><% track(aliases) %><%= "Known Alias".pluralize(aliases.size) %> @@ -125,5 +131,35 @@ describe("ActionViewPreferPluralizeHelperRule", () => { <%= account.aliases(true).size %> <%= "Alias".pluralize(account.aliases(true).size) %> `) }) + + test("supports an interpolated string receiver", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(aliases.size, "#{kind}") %>` instead.', + ) + + assertOffenses(dedent` + <%= aliases.size %> <%= "#{kind}".pluralize(aliases.size) %> + `) + }) + + test("preserves punctuation in the suggested singular", () => { + expectWarning( + 'Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(records.size, "/ Record") %>` instead.', + ) + + assertOffenses(dedent` + <%= records.size %> / <%= "Record".pluralize(records.size) %> + `) + }) + + test("uses a generic suggestion when literal content cannot be represented safely", () => { + expectWarning( + "Prefer the `pluralize` helper over separate count and `String#pluralize` output. Use `<%= pluralize(records.size, singular) %>` instead.", + ) + + assertOffenses( + `<%= records.size %>\tKnown <%= "Record".pluralize(records.size) %>`, + ) + }) }) }) From 248730e8669870aaaff68aa4f06984f5b7f40e87 Mon Sep 17 00:00:00 2001 From: joaoGabriel55 Date: Thu, 6 Aug 2026 09:37:17 -0300 Subject: [PATCH 6/6] build: update cli.test.ts.snap --- .../test/__snapshots__/cli.test.ts.snap | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 81b47e49d..06ad105d6 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -19,7 +19,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 114 enabled | all rules via --all-rules" + Rules 115 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`--only\` replaces the counts from a disabled \`all\` 1`] = ` @@ -50,7 +50,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 0 enabled | 114 not enabled + Rules 0 enabled | 115 not enabled No rules enabled: Every linter rule is turned off, so no offenses can be reported. @@ -71,7 +71,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 0 enabled | 114 not enabled + Rules 0 enabled | 115 not enabled No rules enabled: Every linter rule is turned off, so no offenses can be reported. @@ -102,7 +102,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 114 enabled" + Rules 115 enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`all: enabled: true\` reports no version-skipped rules 1`] = ` @@ -124,7 +124,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 114 enabled" + Rules 115 enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > reports enabled and not-enabled rules when no \`all\` is configured 1`] = ` @@ -146,7 +146,7 @@ test/fixtures/test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted back in on top of \`all: enabled: false\` count as enabled 1`] = ` @@ -165,7 +165,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 1 enabled | 113 not enabled" + Rules 1 enabled | 114 not enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted out on top of \`all: enabled: true\` count as disabled 1`] = ` @@ -184,7 +184,7 @@ test-file-with-errors.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 113 enabled | 1 disabled" + Rules 114 enabled | 1 disabled" `; exports[`CLI Output Formatting > --all-rules > reports every offense for the fixture with --all-rules 1`] = ` @@ -214,7 +214,7 @@ test/fixtures/all-rules.html.erb: Failing 0 offenses Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 114 enabled | all rules via --all-rules" + Rules 115 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture with the default rule set 1`] = ` @@ -226,7 +226,7 @@ exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture w Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > --ignore-disable-comments 1`] = ` @@ -377,7 +377,7 @@ test/fixtures/ignored.html.erb:8:8 Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > --log-level > counts the hidden offenses and suggests the level that reveals them 1`] = ` @@ -395,7 +395,7 @@ exports[`CLI Output Formatting > --log-level > counts the hidden offenses and su Not failing 2 info | 1 hint (3 offenses across 1 file, below --fail-level=error) Not shown 3 offenses hidden, show them with --log-level=hint Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > --log-level > doesn't report offenses below the given level 1`] = ` @@ -412,7 +412,7 @@ exports[`CLI Output Formatting > --log-level > doesn't report offenses below the Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 97 enabled | 16 not enabled | 1 disabled" + Rules 98 enabled | 16 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > prefers the CLI flag over the config file 1`] = ` @@ -431,7 +431,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 97 enabled | 16 not enabled | 1 disabled" + Rules 98 enabled | 16 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > reads logLevel from the config file 1`] = ` @@ -448,7 +448,7 @@ exports[`CLI Output Formatting > --log-level > reads logLevel from the config fi Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 97 enabled | 16 not enabled | 1 disabled" + Rules 98 enabled | 16 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > still counts hidden offenses towards the exit code 1`] = ` @@ -464,7 +464,7 @@ exports[`CLI Output Formatting > --log-level > still counts hidden offenses towa Offenses 1 hint (1 offense across 1 file) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 97 enabled | 16 not enabled | 1 disabled" + Rules 98 enabled | 16 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > still reports offenses at or above the given level 1`] = ` @@ -483,7 +483,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 97 enabled | 16 not enabled | 1 disabled" + Rules 98 enabled | 16 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > with --all-rules > keeps the log level when it is passed explicitly 1`] = ` @@ -505,7 +505,7 @@ test-file-with-errors.html.erb: Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 114 enabled | all rules via --all-rules" + Rules 115 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --log-level > with --all-rules > lowers the log level to report the rules it was asked for 1`] = ` @@ -528,7 +528,7 @@ test-file-with-errors.html.erb: Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Log level hint | lowered from warning by --all-rules Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 114 enabled | all rules via --all-rules" + Rules 115 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --log-level > with --only > keeps the log level when it is passed explicitly 1`] = ` @@ -689,7 +689,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -721,7 +721,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -810,7 +810,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Checked 1 file Offenses 4 errors (4 offenses across 1 file) Fixable 4 offenses | 3 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -870,7 +870,7 @@ test/fixtures/ignored.html.erb:6:14 Offenses 2 errors (2 offenses across 1 file) Ignored 3 offenses suppressed with herb:disable Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -930,7 +930,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Failing 0 offenses Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -956,7 +956,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error (1 offense across 1 file) Fixable 0 offenses - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -1208,7 +1208,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:7 Failing 8 errors (8 offenses across 1 file) Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -1333,7 +1333,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Failing 4 errors (4 offenses across 1 file) Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -1388,7 +1388,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -1400,7 +1400,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -1479,7 +1479,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -1536,7 +1536,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1583,7 +1583,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 98, + "ruleCount": 99, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -1605,7 +1605,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for clean file 1` "summary": { "filesChecked": 1, "filesWithOffenses": 0, - "ruleCount": 98, + "ruleCount": 99, "totalErrors": 0, "totalHints": 0, "totalIgnored": 0, @@ -1679,7 +1679,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for file with err "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 98, + "ruleCount": 99, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -1762,7 +1762,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1781,7 +1781,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1800,7 +1800,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1812,7 +1812,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1824,7 +1824,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1875,7 +1875,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -2018,7 +2018,7 @@ test/fixtures/disabled-1.html.erb:14:19 Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Ignored 8 offenses suppressed with herb:disable Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -2277,7 +2277,7 @@ test/fixtures/disabled-2.html.erb:2:44 Not failing 7 warnings (7 offenses across 1 file, below --fail-level=error) Ignored 5 offenses suppressed with herb:disable Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > points at --log-level when many offenses don't fail the build 1`] = ` @@ -2315,7 +2315,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled + Rules 99 enabled | 16 not enabled TIP: 11 of the logged offenses don't fail the build. Run herb-lint --log-level=error to stop logging them, or set linter.logLevel in your .herb.yml. @@ -2347,7 +2347,7 @@ multiple-rule-offenses.html.erb: Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Not shown 11 offenses hidden, show them with --log-level=hint Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when --log-level is passed explicitly, even when it hides nothing 1`] = ` @@ -2385,7 +2385,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when logLevel is set in the config file 1`] = ` @@ -2423,7 +2423,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when only a handful of offenses don't fail the build 1`] = ` @@ -2461,7 +2461,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors (8 offenses across 1 file) Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > points at the flag instead of previewing once there are too many corrections 1`] = ` @@ -2557,7 +2557,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > previews the correction under each correctable offense 1`] = ` @@ -2614,7 +2614,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is hint 1`] = ` @@ -2651,7 +2651,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is info 1`] = ` @@ -2688,7 +2688,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is warning 1`] = ` @@ -2725,7 +2725,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > moves a severity between buckets as --fail-level is lowered 1`] = ` @@ -2763,7 +2763,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors | 4 warnings | 1 info (13 offenses across 1 file) Not failing 1 hint (1 offense across 1 file, below --fail-level=info) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > splits the buckets when only some severities fail the build 1`] = ` @@ -2801,7 +2801,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors | 4 warnings (12 offenses across 1 file) Not failing 1 info | 1 hint (2 offenses across 1 file, below --fail-level=warning) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `; exports[`CLI Output Formatting > unsafe autocorrectable offenses > counts and tags them separately from offenses --fix can correct 1`] = ` @@ -2921,5 +2921,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 98 enabled | 16 not enabled" + Rules 99 enabled | 16 not enabled" `;